5 Commits

Author SHA1 Message Date
gsxdsm
aa345a50ac feat: Add PR review comments and resolution endpoints, improve prompt handling 2026-02-20 16:08:15 -08:00
gsxdsm
0e020f7e4a Feature: File Editor (#789)
* feat: Add file management feature

* feat: Add auto-save functionality to file editor

* fix: Replace HardDriveDownload icon with Save icon for consistency

* fix: Prevent recursive copy/move and improve shell injection prevention

* refactor: Extract editor settings form into separate component
2026-02-20 16:06:44 -08:00
gsxdsm
0a5540c9a2 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>
2026-02-20 13:48:22 -08:00
gsxdsm
7df2182818 Improve pull request flow, add branch selection for worktree creation, fix auto-mode concurrency count (#787)
* Changes from fix/fetch-before-pull-fetch

* feat: Improve pull request flow, add branch selection for worktree creation, fix for automode concurrency count

* feat: Add validation for remote names and improve error handling

* Address PR comments and mobile layout fixes

* ```
refactor: Extract PR target resolution logic into dedicated service
```

* feat: Add app shell UI and improve service imports. Address PR comments

* fix: Improve security validation and cache handling in git operations

* feat: Add GET /list endpoint and improve parameter handling

* chore: Improve validation, accessibility, and error handling across apps

* chore: Format vite server port configuration

* fix: Add error handling for gh pr list command and improve offline fallbacks

* fix: Preserve existing PR creation time and improve remote handling
2026-02-19 21:55:12 -08:00
DhanushSantosh
ee52333636 chore: refresh lockfile after dependency sync 2026-02-20 00:08:13 +05:30
180 changed files with 18216 additions and 2071 deletions

View File

@@ -133,6 +133,7 @@ jobs:
env: env:
CI: true CI: true
VITE_SERVER_URL: http://localhost:3008 VITE_SERVER_URL: http://localhost:3008
SERVER_URL: http://localhost:3008
VITE_SKIP_SETUP: 'true' VITE_SKIP_SETUP: 'true'
# Keep UI-side login/defaults consistent # Keep UI-side login/defaults consistent
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests AUTOMAKER_API_KEY: test-api-key-for-e2e-tests

2
OPENCODE_CONFIG_CONTENT Normal file
View File

@@ -0,0 +1,2 @@
{
"$schema": "https://opencode.ai/config.json",}

View File

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

View File

@@ -33,6 +33,11 @@ export function createFeaturesRoutes(
validatePathParams('projectPath'), validatePathParams('projectPath'),
createListHandler(featureLoader, autoModeService) createListHandler(featureLoader, autoModeService)
); );
router.get(
'/list',
validatePathParams('projectPath'),
createListHandler(featureLoader, autoModeService)
);
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader)); router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
router.post( router.post(
'/create', '/create',

View File

@@ -1,5 +1,7 @@
/** /**
* POST /list endpoint - List all features for a project * POST/GET /list endpoint - List all features for a project
*
* projectPath may come from req.body (POST) or req.query (GET fallback).
* *
* Also performs orphan detection when a project is loaded to identify * Also performs orphan detection when a project is loaded to identify
* features whose branches no longer exist. This runs on every project load/switch. * features whose branches no longer exist. This runs on every project load/switch.
@@ -19,7 +21,17 @@ export function createListHandler(
) { ) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { projectPath } = req.body as { projectPath: string }; const bodyProjectPath =
typeof req.body === 'object' && req.body !== null
? (req.body as { projectPath?: unknown }).projectPath
: undefined;
const queryProjectPath = req.query.projectPath;
const projectPath =
typeof bodyProjectPath === 'string'
? bodyProjectPath
: typeof queryProjectPath === 'string'
? queryProjectPath
: undefined;
if (!projectPath) { if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' }); res.status(400).json({ success: false, error: 'projectPath is required' });

View File

@@ -20,6 +20,9 @@ import { createImageHandler } from './routes/image.js';
import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js'; import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js';
import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js'; import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js';
import { createBrowseProjectFilesHandler } from './routes/browse-project-files.js'; import { createBrowseProjectFilesHandler } from './routes/browse-project-files.js';
import { createCopyHandler } from './routes/copy.js';
import { createMoveHandler } from './routes/move.js';
import { createDownloadHandler } from './routes/download.js';
export function createFsRoutes(_events: EventEmitter): Router { export function createFsRoutes(_events: EventEmitter): Router {
const router = Router(); const router = Router();
@@ -39,6 +42,9 @@ export function createFsRoutes(_events: EventEmitter): Router {
router.post('/save-board-background', createSaveBoardBackgroundHandler()); router.post('/save-board-background', createSaveBoardBackgroundHandler());
router.post('/delete-board-background', createDeleteBoardBackgroundHandler()); router.post('/delete-board-background', createDeleteBoardBackgroundHandler());
router.post('/browse-project-files', createBrowseProjectFilesHandler()); router.post('/browse-project-files', createBrowseProjectFilesHandler());
router.post('/copy', createCopyHandler());
router.post('/move', createMoveHandler());
router.post('/download', createDownloadHandler());
return router; return router;
} }

View File

@@ -0,0 +1,99 @@
/**
* POST /copy endpoint - Copy file or directory to a new location
*/
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { mkdirSafe } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
/**
* Recursively copy a directory and its contents
*/
async function copyDirectoryRecursive(src: string, dest: string): Promise<void> {
await mkdirSafe(dest);
const entries = await secureFs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirectoryRecursive(srcPath, destPath);
} else {
await secureFs.copyFile(srcPath, destPath);
}
}
}
export function createCopyHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sourcePath, destinationPath, overwrite } = req.body as {
sourcePath: string;
destinationPath: string;
overwrite?: boolean;
};
if (!sourcePath || !destinationPath) {
res
.status(400)
.json({ success: false, error: 'sourcePath and destinationPath are required' });
return;
}
// Prevent copying a folder into itself or its own descendant (infinite recursion)
const resolvedSrc = path.resolve(sourcePath);
const resolvedDest = path.resolve(destinationPath);
if (resolvedDest === resolvedSrc || resolvedDest.startsWith(resolvedSrc + path.sep)) {
res.status(400).json({
success: false,
error: 'Cannot copy a folder into itself or one of its own descendants',
});
return;
}
// Check if destination already exists
try {
await secureFs.stat(destinationPath);
// Destination exists
if (!overwrite) {
res.status(409).json({
success: false,
error: 'Destination already exists',
exists: true,
});
return;
}
// If overwrite is true, remove the existing destination first to avoid merging
await secureFs.rm(destinationPath, { recursive: true });
} catch {
// Destination doesn't exist - good to proceed
}
// Ensure parent directory exists
await mkdirSafe(path.dirname(path.resolve(destinationPath)));
// Check if source is a directory
const stats = await secureFs.stat(sourcePath);
if (stats.isDirectory()) {
await copyDirectoryRecursive(sourcePath, destinationPath);
} else {
await secureFs.copyFile(sourcePath, destinationPath);
}
res.json({ success: true });
} catch (error) {
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Copy file failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,142 @@
/**
* POST /download endpoint - Download a file, or GET /download for streaming
* For folders, creates a zip archive on the fly
*/
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
import { createReadStream } from 'fs';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { tmpdir } from 'os';
const execFileAsync = promisify(execFile);
/**
* Get total size of a directory recursively
*/
async function getDirectorySize(dirPath: string): Promise<number> {
let totalSize = 0;
const entries = await secureFs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
totalSize += await getDirectorySize(entryPath);
} else {
const stats = await secureFs.stat(entryPath);
totalSize += Number(stats.size);
}
}
return totalSize;
}
export function createDownloadHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { filePath } = req.body as { filePath: string };
if (!filePath) {
res.status(400).json({ success: false, error: 'filePath is required' });
return;
}
const stats = await secureFs.stat(filePath);
const fileName = path.basename(filePath);
if (stats.isDirectory()) {
// For directories, create a zip archive
const dirSize = await getDirectorySize(filePath);
const MAX_DIR_SIZE = 100 * 1024 * 1024; // 100MB limit
if (dirSize > MAX_DIR_SIZE) {
res.status(413).json({
success: false,
error: `Directory is too large to download (${(dirSize / (1024 * 1024)).toFixed(1)}MB). Maximum size is ${MAX_DIR_SIZE / (1024 * 1024)}MB.`,
size: dirSize,
});
return;
}
// Create a temporary zip file
const zipFileName = `${fileName}.zip`;
const tmpZipPath = path.join(tmpdir(), `automaker-download-${Date.now()}-${zipFileName}`);
try {
// Use system zip command (available on macOS and Linux)
// Use execFile to avoid shell injection via user-provided paths
await execFileAsync('zip', ['-r', tmpZipPath, fileName], {
cwd: path.dirname(filePath),
maxBuffer: 50 * 1024 * 1024,
});
const zipStats = await secureFs.stat(tmpZipPath);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${zipFileName}"`);
res.setHeader('Content-Length', zipStats.size.toString());
res.setHeader('X-Directory-Size', dirSize.toString());
const stream = createReadStream(tmpZipPath);
stream.pipe(res);
stream.on('end', async () => {
// Cleanup temp file
try {
await secureFs.rm(tmpZipPath);
} catch {
// Ignore cleanup errors
}
});
stream.on('error', async (err) => {
logError(err, 'Download stream error');
try {
await secureFs.rm(tmpZipPath);
} catch {
// Ignore cleanup errors
}
if (!res.headersSent) {
res.status(500).json({ success: false, error: 'Stream error during download' });
}
});
} catch (zipError) {
// Cleanup on zip failure
try {
await secureFs.rm(tmpZipPath);
} catch {
// Ignore
}
throw zipError;
}
} else {
// For individual files, stream directly
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
res.setHeader('Content-Length', stats.size.toString());
const stream = createReadStream(filePath);
stream.pipe(res);
stream.on('error', (err) => {
logError(err, 'Download stream error');
if (!res.headersSent) {
res.status(500).json({ success: false, error: 'Stream error during download' });
}
});
}
} catch (error) {
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Download failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,79 @@
/**
* POST /move endpoint - Move (rename) file or directory to a new location
*/
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { mkdirSafe } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
export function createMoveHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sourcePath, destinationPath, overwrite } = req.body as {
sourcePath: string;
destinationPath: string;
overwrite?: boolean;
};
if (!sourcePath || !destinationPath) {
res
.status(400)
.json({ success: false, error: 'sourcePath and destinationPath are required' });
return;
}
// Prevent moving to same location or into its own descendant
const resolvedSrc = path.resolve(sourcePath);
const resolvedDest = path.resolve(destinationPath);
if (resolvedDest === resolvedSrc) {
// No-op: source and destination are the same
res.json({ success: true });
return;
}
if (resolvedDest.startsWith(resolvedSrc + path.sep)) {
res.status(400).json({
success: false,
error: 'Cannot move a folder into one of its own descendants',
});
return;
}
// Check if destination already exists
try {
await secureFs.stat(destinationPath);
// Destination exists
if (!overwrite) {
res.status(409).json({
success: false,
error: 'Destination already exists',
exists: true,
});
return;
}
// If overwrite is true, remove the existing destination first
await secureFs.rm(destinationPath, { recursive: true });
} catch {
// Destination doesn't exist - good to proceed
}
// Ensure parent directory exists
await mkdirSafe(path.dirname(path.resolve(destinationPath)));
// Use rename for the move operation
await secureFs.rename(sourcePath, destinationPath);
res.json({ success: true });
} catch (error) {
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Move file failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -7,6 +7,8 @@ import { validatePathParams } from '../../middleware/validate-paths.js';
import { createDiffsHandler } from './routes/diffs.js'; import { createDiffsHandler } from './routes/diffs.js';
import { createFileDiffHandler } from './routes/file-diff.js'; import { createFileDiffHandler } from './routes/file-diff.js';
import { createStageFilesHandler } from './routes/stage-files.js'; import { createStageFilesHandler } from './routes/stage-files.js';
import { createDetailsHandler } from './routes/details.js';
import { createEnhancedStatusHandler } from './routes/enhanced-status.js';
export function createGitRoutes(): Router { export function createGitRoutes(): Router {
const router = Router(); const router = Router();
@@ -18,6 +20,8 @@ export function createGitRoutes(): Router {
validatePathParams('projectPath', 'files[]'), validatePathParams('projectPath', 'files[]'),
createStageFilesHandler() createStageFilesHandler()
); );
router.post('/details', validatePathParams('projectPath', 'filePath?'), createDetailsHandler());
router.post('/enhanced-status', validatePathParams('projectPath'), createEnhancedStatusHandler());
return router; return router;
} }

View File

@@ -0,0 +1,248 @@
/**
* POST /details endpoint - Get detailed git info for a file or project
* Returns branch, last commit info, diff stats, and conflict status
*/
import type { Request, Response } from 'express';
import { exec, execFile } from 'child_process';
import { promisify } from 'util';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
interface GitFileDetails {
branch: string;
lastCommitHash: string;
lastCommitMessage: string;
lastCommitAuthor: string;
lastCommitTimestamp: string;
linesAdded: number;
linesRemoved: number;
isConflicted: boolean;
isStaged: boolean;
isUnstaged: boolean;
statusLabel: string;
}
export function createDetailsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, filePath } = req.body as {
projectPath: string;
filePath?: string;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
try {
// Get current branch
const { stdout: branchRaw } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: projectPath,
});
const branch = branchRaw.trim();
if (!filePath) {
// Project-level details - just return branch info
res.json({
success: true,
details: { branch },
});
return;
}
// Get last commit info for this file
let lastCommitHash = '';
let lastCommitMessage = '';
let lastCommitAuthor = '';
let lastCommitTimestamp = '';
try {
const { stdout: logOutput } = await execFileAsync(
'git',
['log', '-1', '--format=%H|%s|%an|%aI', '--', filePath],
{ cwd: projectPath }
);
if (logOutput.trim()) {
const parts = logOutput.trim().split('|');
lastCommitHash = parts[0] || '';
lastCommitMessage = parts[1] || '';
lastCommitAuthor = parts[2] || '';
lastCommitTimestamp = parts[3] || '';
}
} catch {
// File may not have any commits yet
}
// Get diff stats (lines added/removed)
let linesAdded = 0;
let linesRemoved = 0;
try {
// Check if file is untracked first
const { stdout: statusLine } = await execFileAsync(
'git',
['status', '--porcelain', '--', filePath],
{ cwd: projectPath }
);
if (statusLine.trim().startsWith('??')) {
// Untracked file - count all lines as added using Node.js instead of shell
try {
const fileContent = (await secureFs.readFile(filePath, 'utf-8')).toString();
const lines = fileContent.split('\n');
// Don't count trailing empty line from final newline
linesAdded =
lines.length > 0 && lines[lines.length - 1] === ''
? lines.length - 1
: lines.length;
} catch {
// Ignore
}
} else {
const { stdout: diffStatRaw } = await execFileAsync(
'git',
['diff', '--numstat', 'HEAD', '--', filePath],
{ cwd: projectPath }
);
if (diffStatRaw.trim()) {
const parts = diffStatRaw.trim().split('\t');
linesAdded = parseInt(parts[0], 10) || 0;
linesRemoved = parseInt(parts[1], 10) || 0;
}
// Also check staged diff stats
const { stdout: stagedDiffStatRaw } = await execFileAsync(
'git',
['diff', '--numstat', '--cached', '--', filePath],
{ cwd: projectPath }
);
if (stagedDiffStatRaw.trim()) {
const parts = stagedDiffStatRaw.trim().split('\t');
linesAdded += parseInt(parts[0], 10) || 0;
linesRemoved += parseInt(parts[1], 10) || 0;
}
}
} catch {
// Diff might not be available
}
// Get conflict and staging status
let isConflicted = false;
let isStaged = false;
let isUnstaged = false;
let statusLabel = '';
try {
const { stdout: statusOutput } = await execFileAsync(
'git',
['status', '--porcelain', '--', filePath],
{ cwd: projectPath }
);
if (statusOutput.trim()) {
const indexStatus = statusOutput[0];
const workTreeStatus = statusOutput[1];
// Check for conflicts (both modified, unmerged states)
if (
indexStatus === 'U' ||
workTreeStatus === 'U' ||
(indexStatus === 'A' && workTreeStatus === 'A') ||
(indexStatus === 'D' && workTreeStatus === 'D')
) {
isConflicted = true;
statusLabel = 'Conflicted';
} else {
// Staged changes (index has a status)
if (indexStatus !== ' ' && indexStatus !== '?') {
isStaged = true;
}
// Unstaged changes (work tree has a status)
if (workTreeStatus !== ' ' && workTreeStatus !== '?') {
isUnstaged = true;
}
// Build status label
if (isStaged && isUnstaged) {
statusLabel = 'Staged + Modified';
} else if (isStaged) {
statusLabel = 'Staged';
} else {
const statusChar = workTreeStatus !== ' ' ? workTreeStatus : indexStatus;
switch (statusChar) {
case 'M':
statusLabel = 'Modified';
break;
case 'A':
statusLabel = 'Added';
break;
case 'D':
statusLabel = 'Deleted';
break;
case 'R':
statusLabel = 'Renamed';
break;
case 'C':
statusLabel = 'Copied';
break;
case '?':
statusLabel = 'Untracked';
break;
default:
statusLabel = statusChar || '';
}
}
}
}
} catch {
// Status might not be available
}
const details: GitFileDetails = {
branch,
lastCommitHash,
lastCommitMessage,
lastCommitAuthor,
lastCommitTimestamp,
linesAdded,
linesRemoved,
isConflicted,
isStaged,
isUnstaged,
statusLabel,
};
res.json({ success: true, details });
} catch (innerError) {
logError(innerError, 'Git details failed');
res.json({
success: true,
details: {
branch: '',
lastCommitHash: '',
lastCommitMessage: '',
lastCommitAuthor: '',
lastCommitTimestamp: '',
linesAdded: 0,
linesRemoved: 0,
isConflicted: false,
isStaged: false,
isUnstaged: false,
statusLabel: '',
},
});
}
} catch (error) {
logError(error, 'Get git details failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

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

View File

@@ -0,0 +1,176 @@
/**
* POST /enhanced-status endpoint - Get enhanced git status with diff stats per file
* Returns per-file status with lines added/removed and staged/unstaged differentiation
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
interface EnhancedFileStatus {
path: string;
indexStatus: string;
workTreeStatus: string;
isConflicted: boolean;
isStaged: boolean;
isUnstaged: boolean;
linesAdded: number;
linesRemoved: number;
statusLabel: string;
}
function getStatusLabel(indexStatus: string, workTreeStatus: string): string {
// Check for conflicts
if (
indexStatus === 'U' ||
workTreeStatus === 'U' ||
(indexStatus === 'A' && workTreeStatus === 'A') ||
(indexStatus === 'D' && workTreeStatus === 'D')
) {
return 'Conflicted';
}
const hasStaged = indexStatus !== ' ' && indexStatus !== '?';
const hasUnstaged = workTreeStatus !== ' ' && workTreeStatus !== '?';
if (hasStaged && hasUnstaged) return 'Staged + Modified';
if (hasStaged) return 'Staged';
const statusChar = workTreeStatus !== ' ' ? workTreeStatus : indexStatus;
switch (statusChar) {
case 'M':
return 'Modified';
case 'A':
return 'Added';
case 'D':
return 'Deleted';
case 'R':
return 'Renamed';
case 'C':
return 'Copied';
case '?':
return 'Untracked';
default:
return statusChar || '';
}
}
export function createEnhancedStatusHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
try {
// Get current branch
const { stdout: branchRaw } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: projectPath,
});
const branch = branchRaw.trim();
// Get porcelain status for all files
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
cwd: projectPath,
});
// Get diff numstat for working tree changes
let workTreeStats: Record<string, { added: number; removed: number }> = {};
try {
const { stdout: numstatRaw } = await execAsync('git diff --numstat', {
cwd: projectPath,
maxBuffer: 10 * 1024 * 1024,
});
for (const line of numstatRaw.trim().split('\n').filter(Boolean)) {
const parts = line.split('\t');
if (parts.length >= 3) {
const added = parseInt(parts[0], 10) || 0;
const removed = parseInt(parts[1], 10) || 0;
workTreeStats[parts[2]] = { added, removed };
}
}
} catch {
// Ignore
}
// Get diff numstat for staged changes
let stagedStats: Record<string, { added: number; removed: number }> = {};
try {
const { stdout: stagedNumstatRaw } = await execAsync('git diff --numstat --cached', {
cwd: projectPath,
maxBuffer: 10 * 1024 * 1024,
});
for (const line of stagedNumstatRaw.trim().split('\n').filter(Boolean)) {
const parts = line.split('\t');
if (parts.length >= 3) {
const added = parseInt(parts[0], 10) || 0;
const removed = parseInt(parts[1], 10) || 0;
stagedStats[parts[2]] = { added, removed };
}
}
} catch {
// Ignore
}
// Parse status and build enhanced file list
const files: EnhancedFileStatus[] = [];
for (const line of statusOutput.split('\n').filter(Boolean)) {
if (line.length < 4) continue;
const indexStatus = line[0];
const workTreeStatus = line[1];
const filePath = line.substring(3).trim();
// Handle renamed files (format: "R old -> new")
const actualPath = filePath.includes(' -> ')
? filePath.split(' -> ')[1].trim()
: filePath;
const isConflicted =
indexStatus === 'U' ||
workTreeStatus === 'U' ||
(indexStatus === 'A' && workTreeStatus === 'A') ||
(indexStatus === 'D' && workTreeStatus === 'D');
const isStaged = indexStatus !== ' ' && indexStatus !== '?';
const isUnstaged = workTreeStatus !== ' ' && workTreeStatus !== '?';
// Combine diff stats from both working tree and staged
const wtStats = workTreeStats[actualPath] || { added: 0, removed: 0 };
const stStats = stagedStats[actualPath] || { added: 0, removed: 0 };
files.push({
path: actualPath,
indexStatus,
workTreeStatus,
isConflicted,
isStaged,
isUnstaged,
linesAdded: wtStats.added + stStats.added,
linesRemoved: wtStats.removed + stStats.removed,
statusLabel: getStatusLabel(indexStatus, workTreeStatus),
});
}
res.json({
success: true,
branch,
files,
});
} catch (innerError) {
logError(innerError, 'Git enhanced status failed');
res.json({ success: true, branch: '', files: [] });
}
} catch (error) {
logError(error, 'Get enhanced status failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -9,6 +9,8 @@ import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js'
import { createListIssuesHandler } from './routes/list-issues.js'; import { createListIssuesHandler } from './routes/list-issues.js';
import { createListPRsHandler } from './routes/list-prs.js'; import { createListPRsHandler } from './routes/list-prs.js';
import { createListCommentsHandler } from './routes/list-comments.js'; import { createListCommentsHandler } from './routes/list-comments.js';
import { createListPRReviewCommentsHandler } from './routes/list-pr-review-comments.js';
import { createResolvePRCommentHandler } from './routes/resolve-pr-comment.js';
import { createValidateIssueHandler } from './routes/validate-issue.js'; import { createValidateIssueHandler } from './routes/validate-issue.js';
import { import {
createValidationStatusHandler, createValidationStatusHandler,
@@ -29,6 +31,16 @@ export function createGitHubRoutes(
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler()); router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler()); router.post('/prs', validatePathParams('projectPath'), createListPRsHandler());
router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler()); router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler());
router.post(
'/pr-review-comments',
validatePathParams('projectPath'),
createListPRReviewCommentsHandler()
);
router.post(
'/resolve-pr-comment',
validatePathParams('projectPath'),
createResolvePRCommentHandler()
);
router.post( router.post(
'/validate-issue', '/validate-issue',
validatePathParams('projectPath'), validatePathParams('projectPath'),

View File

@@ -0,0 +1,333 @@
/**
* POST /pr-review-comments endpoint - Fetch review comments for a GitHub PR
*
* Fetches both regular PR comments and inline code review comments
* for a specific pull request, providing file path and line context.
*/
import { spawn } from 'child_process';
import type { Request, Response } from 'express';
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js';
export interface PRReviewComment {
id: string;
author: string;
avatarUrl?: string;
body: string;
path?: string;
line?: number;
createdAt: string;
updatedAt?: string;
isReviewComment: boolean;
/** Whether this is an outdated review comment (code has changed since) */
isOutdated?: boolean;
/** Whether the review thread containing this comment has been resolved */
isResolved?: boolean;
/** The GraphQL node ID of the review thread (used for resolve/unresolve mutations) */
threadId?: string;
/** The diff hunk context for the comment */
diffHunk?: string;
/** The side of the diff (LEFT or RIGHT) */
side?: string;
/** The commit ID the comment was made on */
commitId?: string;
}
export interface ListPRReviewCommentsResult {
success: boolean;
comments?: PRReviewComment[];
totalCount?: number;
error?: string;
}
interface ListPRReviewCommentsRequest {
projectPath: string;
prNumber: number;
}
/** Timeout for GitHub GraphQL API requests in milliseconds */
const GITHUB_API_TIMEOUT_MS = 30000;
interface GraphQLReviewThreadComment {
databaseId: number;
}
interface GraphQLReviewThread {
id: string;
isResolved: boolean;
comments: {
nodes: GraphQLReviewThreadComment[];
};
}
interface GraphQLResponse {
data?: {
repository?: {
pullRequest?: {
reviewThreads?: {
nodes: GraphQLReviewThread[];
};
} | null;
};
};
errors?: Array<{ message: string }>;
}
interface ReviewThreadInfo {
isResolved: boolean;
threadId: string;
}
/**
* Fetch review thread resolved status and thread IDs using GitHub GraphQL API.
* Returns a map of comment ID (string) -> { isResolved, threadId }.
*/
async function fetchReviewThreadResolvedStatus(
projectPath: string,
owner: string,
repo: string,
prNumber: number
): Promise<Map<string, ReviewThreadInfo>> {
const resolvedMap = new Map<string, ReviewThreadInfo>();
const query = `
query GetPRReviewThreads(
$owner: String!
$repo: String!
$prNumber: Int!
) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
reviewThreads(first: 100) {
nodes {
id
isResolved
comments(first: 100) {
nodes {
databaseId
}
}
}
}
}
}
}`;
const variables = { owner, repo, prNumber };
const requestBody = JSON.stringify({ query, variables });
try {
const response = await new Promise<GraphQLResponse>((resolve, reject) => {
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
cwd: projectPath,
env: execEnv,
});
const timeoutId = setTimeout(() => {
gh.kill();
reject(new Error('GitHub GraphQL API request timed out'));
}, GITHUB_API_TIMEOUT_MS);
let stdout = '';
let stderr = '';
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
gh.on('close', (code) => {
clearTimeout(timeoutId);
if (code !== 0) {
return reject(new Error(`gh process exited with code ${code}: ${stderr}`));
}
try {
resolve(JSON.parse(stdout));
} catch (e) {
reject(e);
}
});
gh.stdin.write(requestBody);
gh.stdin.end();
});
if (response.errors && response.errors.length > 0) {
throw new Error(response.errors[0].message);
}
const threads = response.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
for (const thread of threads) {
const info: ReviewThreadInfo = { isResolved: thread.isResolved, threadId: thread.id };
for (const comment of thread.comments.nodes) {
resolvedMap.set(String(comment.databaseId), info);
}
}
} catch (error) {
// Log but don't fail — resolved status is best-effort
logError(error, 'Failed to fetch PR review thread resolved status');
}
return resolvedMap;
}
/**
* Fetch all comments for a PR (both regular and inline review comments)
*/
async function fetchPRReviewComments(
projectPath: string,
owner: string,
repo: string,
prNumber: number
): Promise<PRReviewComment[]> {
const allComments: PRReviewComment[] = [];
// Fetch review thread resolved status in parallel with comment fetching
const resolvedStatusPromise = fetchReviewThreadResolvedStatus(projectPath, owner, repo, prNumber);
// 1. Fetch regular PR comments (issue-level comments)
try {
const { stdout: commentsOutput } = await execAsync(
`gh pr view ${prNumber} -R ${owner}/${repo} --json comments`,
{
cwd: projectPath,
env: execEnv,
}
);
const commentsData = JSON.parse(commentsOutput);
const regularComments = (commentsData.comments || []).map(
(c: {
id: string;
author: { login: string; avatarUrl?: string };
body: string;
createdAt: string;
updatedAt?: string;
}) => ({
id: String(c.id),
author: c.author?.login || 'unknown',
avatarUrl: c.author?.avatarUrl,
body: c.body,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
isReviewComment: false,
isOutdated: false,
// Regular PR comments are not part of review threads, so not resolvable
isResolved: false,
})
);
allComments.push(...regularComments);
} catch (error) {
logError(error, 'Failed to fetch regular PR comments');
}
// 2. Fetch inline review comments (code-level comments with file/line info)
try {
const reviewsEndpoint = `repos/${owner}/${repo}/pulls/${prNumber}/comments`;
const { stdout: reviewsOutput } = await execAsync(`gh api ${reviewsEndpoint} --paginate`, {
cwd: projectPath,
env: execEnv,
});
const reviewsData = JSON.parse(reviewsOutput);
const reviewComments = (Array.isArray(reviewsData) ? reviewsData : []).map(
(c: {
id: number;
user: { login: string; avatar_url?: string };
body: string;
path: string;
line?: number;
original_line?: number;
created_at: string;
updated_at?: string;
diff_hunk?: string;
side?: string;
commit_id?: string;
position?: number | null;
}) => ({
id: String(c.id),
author: c.user?.login || 'unknown',
avatarUrl: c.user?.avatar_url,
body: c.body,
path: c.path,
line: c.line || c.original_line,
createdAt: c.created_at,
updatedAt: c.updated_at,
isReviewComment: true,
// A review comment is "outdated" if position is null (code has changed)
isOutdated: c.position === null && !c.line,
// isResolved will be filled in below from GraphQL data
isResolved: false,
diffHunk: c.diff_hunk,
side: c.side,
commitId: c.commit_id,
})
);
allComments.push(...reviewComments);
} catch (error) {
logError(error, 'Failed to fetch inline review comments');
}
// Wait for resolved status and apply to inline review comments
const resolvedMap = await resolvedStatusPromise;
if (resolvedMap.size > 0) {
for (const comment of allComments) {
if (comment.isReviewComment && resolvedMap.has(comment.id)) {
const info = resolvedMap.get(comment.id);
comment.isResolved = info?.isResolved ?? false;
comment.threadId = info?.threadId;
}
}
}
// Sort by createdAt descending (newest first)
allComments.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return allComments;
}
export function createListPRReviewCommentsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, prNumber } = req.body as ListPRReviewCommentsRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!prNumber || typeof prNumber !== 'number') {
res
.status(400)
.json({ success: false, error: 'prNumber is required and must be a number' });
return;
}
// Check if this is a GitHub repo and get owner/repo
const remoteStatus = await checkGitHubRemote(projectPath);
if (!remoteStatus.hasGitHubRemote || !remoteStatus.owner || !remoteStatus.repo) {
res.status(400).json({
success: false,
error: 'Project does not have a GitHub remote',
});
return;
}
const comments = await fetchPRReviewComments(
projectPath,
remoteStatus.owner,
remoteStatus.repo,
prNumber
);
res.json({
success: true,
comments,
totalCount: comments.length,
});
} catch (error) {
logError(error, 'Fetch PR review comments failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,151 @@
/**
* POST /resolve-pr-comment endpoint - Resolve or unresolve a GitHub PR review thread
*
* Uses the GitHub GraphQL API to resolve or unresolve a review thread
* identified by its GraphQL node ID (threadId).
*/
import { spawn } from 'child_process';
import type { Request, Response } from 'express';
import { execEnv, getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js';
export interface ResolvePRCommentResult {
success: boolean;
isResolved?: boolean;
error?: string;
}
interface ResolvePRCommentRequest {
projectPath: string;
threadId: string;
resolve: boolean;
}
/** Timeout for GitHub GraphQL API requests in milliseconds */
const GITHUB_API_TIMEOUT_MS = 30000;
interface GraphQLMutationResponse {
data?: {
resolveReviewThread?: {
thread?: { isResolved: boolean; id: string } | null;
} | null;
unresolveReviewThread?: {
thread?: { isResolved: boolean; id: string } | null;
} | null;
};
errors?: Array<{ message: string }>;
}
/**
* Execute a GraphQL mutation to resolve or unresolve a review thread.
*/
async function executeReviewThreadMutation(
projectPath: string,
threadId: string,
resolve: boolean
): Promise<{ isResolved: boolean }> {
const mutationName = resolve ? 'resolveReviewThread' : 'unresolveReviewThread';
const mutation = `
mutation ${resolve ? 'ResolveThread' : 'UnresolveThread'}($threadId: ID!) {
${mutationName}(input: { threadId: $threadId }) {
thread {
id
isResolved
}
}
}`;
const variables = { threadId };
const requestBody = JSON.stringify({ query: mutation, variables });
const response = await new Promise<GraphQLMutationResponse>((res, rej) => {
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
cwd: projectPath,
env: execEnv,
});
const timeoutId = setTimeout(() => {
gh.kill();
rej(new Error('GitHub GraphQL API request timed out'));
}, GITHUB_API_TIMEOUT_MS);
let stdout = '';
let stderr = '';
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
gh.on('close', (code) => {
clearTimeout(timeoutId);
if (code !== 0) {
return rej(new Error(`gh process exited with code ${code}: ${stderr}`));
}
try {
res(JSON.parse(stdout));
} catch (e) {
rej(e);
}
});
gh.stdin.write(requestBody);
gh.stdin.end();
});
if (response.errors && response.errors.length > 0) {
throw new Error(response.errors[0].message);
}
const threadData = resolve
? response.data?.resolveReviewThread?.thread
: response.data?.unresolveReviewThread?.thread;
if (!threadData) {
throw new Error('No thread data returned from GitHub API');
}
return { isResolved: threadData.isResolved };
}
export function createResolvePRCommentHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, threadId, resolve } = req.body as ResolvePRCommentRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!threadId) {
res.status(400).json({ success: false, error: 'threadId is required' });
return;
}
if (typeof resolve !== 'boolean') {
res.status(400).json({ success: false, error: 'resolve must be a boolean' });
return;
}
// Check if this is a GitHub repo
const remoteStatus = await checkGitHubRemote(projectPath);
if (!remoteStatus.hasGitHubRemote) {
res.status(400).json({
success: false,
error: 'Project does not have a GitHub remote',
});
return;
}
const result = await executeReviewThreadMutation(projectPath, threadId, resolve);
res.json({
success: true,
isResolved: result.isResolved,
});
} catch (error) {
logError(error, 'Resolve PR comment failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -2,7 +2,12 @@
* Common utilities for worktree routes * Common utilities for worktree routes
*/ */
import { createLogger, isValidBranchName, MAX_BRANCH_NAME_LENGTH } from '@automaker/utils'; import {
createLogger,
isValidBranchName,
isValidRemoteName,
MAX_BRANCH_NAME_LENGTH,
} from '@automaker/utils';
import { exec } from 'child_process'; import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
@@ -16,7 +21,7 @@ export const execAsync = promisify(exec);
// Re-export git validation utilities from the canonical shared module so // Re-export git validation utilities from the canonical shared module so
// existing consumers that import from this file continue to work. // existing consumers that import from this file continue to work.
export { isValidBranchName, MAX_BRANCH_NAME_LENGTH }; export { isValidBranchName, isValidRemoteName, MAX_BRANCH_NAME_LENGTH };
// ============================================================================ // ============================================================================
// Extended PATH configuration for Electron apps // Extended PATH configuration for Electron apps
@@ -60,25 +65,6 @@ export const execEnv = {
PATH: extendedPath, PATH: extendedPath,
}; };
/**
* Validate git remote name to prevent command injection.
* Matches the strict validation used in add-remote.ts:
* - Rejects empty strings and names that are too long
* - Disallows names that start with '-' or '.'
* - Forbids the substring '..'
* - Rejects '/' characters
* - Rejects NUL bytes
* - Must consist only of alphanumerics, hyphens, underscores, and dots
*/
export function isValidRemoteName(name: string): boolean {
if (!name || name.length === 0 || name.length >= MAX_BRANCH_NAME_LENGTH) return false;
if (name.startsWith('-') || name.startsWith('.')) return false;
if (name.includes('..')) return false;
if (name.includes('/')) return false;
if (name.includes('\0')) return false;
return /^[a-zA-Z0-9._-]+$/.test(name);
}
/** /**
* Check if gh CLI is available on the system * Check if gh CLI is available on the system
*/ */

View File

@@ -22,6 +22,36 @@ import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import { execGitCommand } from '../../../lib/git.js'; import { execGitCommand } from '../../../lib/git.js';
import type { EventEmitter } from '../../../lib/events.js'; import type { EventEmitter } from '../../../lib/events.js';
import { performCheckoutBranch } from '../../../services/checkout-branch-service.js'; import { performCheckoutBranch } from '../../../services/checkout-branch-service.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('CheckoutBranchRoute');
/** Timeout for git fetch operations (30 seconds) */
const FETCH_TIMEOUT_MS = 30_000;
/**
* Fetch latest from all remotes (silently, with timeout).
* Non-fatal: fetch errors are logged and swallowed so the workflow continues.
*/
async function fetchRemotes(cwd: string): Promise<void> {
const controller = new AbortController();
const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller);
} catch (error) {
if (error instanceof Error && error.message === 'Process aborted') {
logger.warn(
`fetchRemotes timed out after ${FETCH_TIMEOUT_MS}ms - continuing without latest remote refs`
);
} else {
logger.warn(`fetchRemotes failed: ${getErrorMessage(error)} - continuing with local refs`);
}
// Non-fatal: continue with locally available refs
} finally {
clearTimeout(timerId);
}
}
export function createCheckoutBranchHandler(events?: EventEmitter) { export function createCheckoutBranchHandler(events?: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -127,6 +157,10 @@ export function createCheckoutBranchHandler(events?: EventEmitter) {
} }
// Original simple flow (no stash handling) // Original simple flow (no stash handling)
// Fetch latest remote refs before creating the branch so that
// base branch validation works for remote references like "origin/main"
await fetchRemotes(resolvedPath);
const currentBranchOutput = await execGitCommand( const currentBranchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'], ['rev-parse', '--abbrev-ref', 'HEAD'],
resolvedPath resolvedPath

View File

@@ -17,6 +17,7 @@ import { spawnProcess } from '@automaker/platform';
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js'; import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
import { validatePRState } from '@automaker/types'; import { validatePRState } from '@automaker/types';
import { resolvePrTarget } from '../../../services/pr-service.js';
const logger = createLogger('CreatePR'); const logger = createLogger('CreatePR');
@@ -32,6 +33,7 @@ export function createCreatePRHandler() {
baseBranch, baseBranch,
draft, draft,
remote, remote,
targetRemote,
} = req.body as { } = req.body as {
worktreePath: string; worktreePath: string;
projectPath?: string; projectPath?: string;
@@ -41,6 +43,8 @@ export function createCreatePRHandler() {
baseBranch?: string; baseBranch?: string;
draft?: boolean; draft?: boolean;
remote?: string; remote?: string;
/** Remote to create the PR against (e.g. upstream). If not specified, inferred from repo setup. */
targetRemote?: string;
}; };
if (!worktreePath) { if (!worktreePath) {
@@ -71,6 +75,52 @@ export function createCreatePRHandler() {
return; return;
} }
// --- Input validation: run all validation before any git write operations ---
// Validate remote names before use to prevent command injection
if (remote !== undefined && !isValidRemoteName(remote)) {
res.status(400).json({
success: false,
error: 'Invalid remote name contains unsafe characters',
});
return;
}
if (targetRemote !== undefined && !isValidRemoteName(targetRemote)) {
res.status(400).json({
success: false,
error: 'Invalid target remote name contains unsafe characters',
});
return;
}
const pushRemote = remote || 'origin';
// Resolve repository URL, fork workflow, and target remote information.
// This is needed for both the existing PR check and PR creation.
// Resolve early so validation errors are caught before any writes.
let repoUrl: string | null = null;
let upstreamRepo: string | null = null;
let originOwner: string | null = null;
try {
const prTarget = await resolvePrTarget({
worktreePath,
pushRemote,
targetRemote,
});
repoUrl = prTarget.repoUrl;
upstreamRepo = prTarget.upstreamRepo;
originOwner = prTarget.originOwner;
} catch (resolveErr) {
// resolvePrTarget throws for validation errors (unknown targetRemote, missing pushRemote)
res.status(400).json({
success: false,
error: getErrorMessage(resolveErr),
});
return;
}
// --- Validation complete — proceed with git operations ---
// Check for uncommitted changes // Check for uncommitted changes
logger.debug(`Checking for uncommitted changes in: ${worktreePath}`); logger.debug(`Checking for uncommitted changes in: ${worktreePath}`);
const { stdout: status } = await execAsync('git status --porcelain', { const { stdout: status } = await execAsync('git status --porcelain', {
@@ -119,30 +169,19 @@ export function createCreatePRHandler() {
} }
} }
// Validate remote name before use to prevent command injection
if (remote !== undefined && !isValidRemoteName(remote)) {
res.status(400).json({
success: false,
error: 'Invalid remote name contains unsafe characters',
});
return;
}
// Push the branch to remote (use selected remote or default to 'origin') // Push the branch to remote (use selected remote or default to 'origin')
const pushRemote = remote || 'origin'; // Uses array-based execGitCommand to avoid shell injection from pushRemote/branchName.
let pushError: string | null = null; let pushError: string | null = null;
try { try {
await execAsync(`git push ${pushRemote} ${branchName}`, { await execGitCommand(['push', pushRemote, branchName], worktreePath, execEnv);
cwd: worktreePath,
env: execEnv,
});
} catch { } catch {
// If push fails, try with --set-upstream // If push fails, try with --set-upstream
try { try {
await execAsync(`git push --set-upstream ${pushRemote} ${branchName}`, { await execGitCommand(
cwd: worktreePath, ['push', '--set-upstream', pushRemote, branchName],
env: execEnv, worktreePath,
}); execEnv
);
} catch (error2: unknown) { } catch (error2: unknown) {
// Capture push error for reporting // Capture push error for reporting
const err = error2 as { stderr?: string; message?: string }; const err = error2 as { stderr?: string; message?: string };
@@ -164,82 +203,11 @@ export function createCreatePRHandler() {
const base = baseBranch || 'main'; const base = baseBranch || 'main';
const title = prTitle || branchName; const title = prTitle || branchName;
const body = prBody || `Changes from branch ${branchName}`; const body = prBody || `Changes from branch ${branchName}`;
const draftFlag = draft ? '--draft' : '';
let prUrl: string | null = null; let prUrl: string | null = null;
let prError: string | null = null; let prError: string | null = null;
let browserUrl: string | null = null; let browserUrl: string | null = null;
let ghCliAvailable = false; let ghCliAvailable = false;
// Get repository URL and detect fork workflow FIRST
// This is needed for both the existing PR check and PR creation
let repoUrl: string | null = null;
let upstreamRepo: string | null = null;
let originOwner: string | null = null;
try {
const { stdout: remotes } = await execAsync('git remote -v', {
cwd: worktreePath,
env: execEnv,
});
// Parse remotes to detect fork workflow and get repo URL
const lines = remotes.split(/\r?\n/); // Handle both Unix and Windows line endings
for (const line of lines) {
// Try multiple patterns to match different remote URL formats
// Pattern 1: git@github.com:owner/repo.git (fetch)
// Pattern 2: https://github.com/owner/repo.git (fetch)
// Pattern 3: https://github.com/owner/repo (fetch)
let match = line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/);
if (!match) {
// Try SSH format: git@github.com:owner/repo.git
match = line.match(/^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/);
}
if (!match) {
// Try HTTPS format: https://github.com/owner/repo.git
match = line.match(
/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/
);
}
if (match) {
const [, remoteName, owner, repo] = match;
if (remoteName === 'upstream') {
upstreamRepo = `${owner}/${repo}`;
repoUrl = `https://github.com/${owner}/${repo}`;
} else if (remoteName === 'origin') {
originOwner = owner;
if (!repoUrl) {
repoUrl = `https://github.com/${owner}/${repo}`;
}
}
}
}
} catch {
// Couldn't parse remotes - will try fallback
}
// Fallback: Try to get repo URL from git config if remote parsing failed
if (!repoUrl) {
try {
const { stdout: originUrl } = await execAsync('git config --get remote.origin.url', {
cwd: worktreePath,
env: execEnv,
});
const url = originUrl.trim();
// Parse URL to extract owner/repo
// Handle both SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git)
let match = url.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
if (match) {
const [, owner, repo] = match;
originOwner = owner;
repoUrl = `https://github.com/${owner}/${repo}`;
}
} catch {
// Failed to get repo URL from config
}
}
// Check if gh CLI is available (cross-platform) // Check if gh CLI is available (cross-platform)
ghCliAvailable = await isGhCliAvailable(); ghCliAvailable = await isGhCliAvailable();
@@ -247,13 +215,16 @@ export function createCreatePRHandler() {
if (repoUrl) { if (repoUrl) {
const encodedTitle = encodeURIComponent(title); const encodedTitle = encodeURIComponent(title);
const encodedBody = encodeURIComponent(body); const encodedBody = encodeURIComponent(body);
// Encode base branch and head branch to handle special chars like # or %
const encodedBase = encodeURIComponent(base);
const encodedBranch = encodeURIComponent(branchName);
if (upstreamRepo && originOwner) { if (upstreamRepo && originOwner) {
// Fork workflow: PR to upstream from origin // Fork workflow (or cross-remote PR): PR to target from push remote
browserUrl = `https://github.com/${upstreamRepo}/compare/${base}...${originOwner}:${branchName}?expand=1&title=${encodedTitle}&body=${encodedBody}`; browserUrl = `https://github.com/${upstreamRepo}/compare/${encodedBase}...${originOwner}:${encodedBranch}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
} else { } else {
// Regular repo // Regular repo
browserUrl = `${repoUrl}/compare/${base}...${branchName}?expand=1&title=${encodedTitle}&body=${encodedBody}`; browserUrl = `${repoUrl}/compare/${encodedBase}...${encodedBranch}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
} }
} }
@@ -263,18 +234,40 @@ export function createCreatePRHandler() {
if (ghCliAvailable) { if (ghCliAvailable) {
// First, check if a PR already exists for this branch using gh pr list // First, check if a PR already exists for this branch using gh pr list
// This is more reliable than gh pr view as it explicitly searches by branch name // This is more reliable than gh pr view as it explicitly searches by branch name
// For forks, we need to use owner:branch format for the head parameter // For forks/cross-remote, we need to use owner:branch format for the head parameter
const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName; const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName;
const repoArg = upstreamRepo ? ` --repo "${upstreamRepo}"` : '';
logger.debug(`Checking for existing PR for branch: ${branchName} (headRef: ${headRef})`); logger.debug(`Checking for existing PR for branch: ${branchName} (headRef: ${headRef})`);
try { try {
const listCmd = `gh pr list${repoArg} --head "${headRef}" --json number,title,url,state --limit 1`; const listArgs = ['pr', 'list'];
logger.debug(`Running: ${listCmd}`); if (upstreamRepo) {
const { stdout: existingPrOutput } = await execAsync(listCmd, { listArgs.push('--repo', upstreamRepo);
}
listArgs.push(
'--head',
headRef,
'--json',
'number,title,url,state,createdAt',
'--limit',
'1'
);
logger.debug(`Running: gh ${listArgs.join(' ')}`);
const listResult = await spawnProcess({
command: 'gh',
args: listArgs,
cwd: worktreePath, cwd: worktreePath,
env: execEnv, env: execEnv,
}); });
if (listResult.exitCode !== 0) {
logger.error(
`gh pr list failed with exit code ${listResult.exitCode}: ` +
`stderr=${listResult.stderr}, stdout=${listResult.stdout}`
);
throw new Error(
`gh pr list failed (exit code ${listResult.exitCode}): ${listResult.stderr || listResult.stdout}`
);
}
const existingPrOutput = listResult.stdout;
logger.debug(`gh pr list output: ${existingPrOutput}`); logger.debug(`gh pr list output: ${existingPrOutput}`);
const existingPrs = JSON.parse(existingPrOutput); const existingPrs = JSON.parse(existingPrOutput);
@@ -294,7 +287,7 @@ export function createCreatePRHandler() {
url: existingPr.url, url: existingPr.url,
title: existingPr.title || title, title: existingPr.title || title,
state: validatePRState(existingPr.state), state: validatePRState(existingPr.state),
createdAt: new Date().toISOString(), createdAt: existingPr.createdAt || new Date().toISOString(),
}); });
logger.debug( logger.debug(
`Stored existing PR info for branch ${branchName}: PR #${existingPr.number}` `Stored existing PR info for branch ${branchName}: PR #${existingPr.number}`
@@ -372,11 +365,26 @@ export function createCreatePRHandler() {
if (errorMessage.toLowerCase().includes('already exists')) { if (errorMessage.toLowerCase().includes('already exists')) {
logger.debug(`PR already exists error - trying to fetch existing PR`); logger.debug(`PR already exists error - trying to fetch existing PR`);
try { try {
const { stdout: viewOutput } = await execAsync( // Build args as an array to avoid shell injection.
`gh pr view --json number,title,url,state`, // When upstreamRepo is set (fork/cross-remote workflow) we must
{ cwd: worktreePath, env: execEnv } // query the upstream repository so we find the correct PR.
); const viewArgs = ['pr', 'view', '--json', 'number,title,url,state,createdAt'];
const existingPr = JSON.parse(viewOutput); if (upstreamRepo) {
viewArgs.push('--repo', upstreamRepo);
}
logger.debug(`Running: gh ${viewArgs.join(' ')}`);
const viewResult = await spawnProcess({
command: 'gh',
args: viewArgs,
cwd: worktreePath,
env: execEnv,
});
if (viewResult.exitCode !== 0) {
throw new Error(
`gh pr view failed (exit code ${viewResult.exitCode}): ${viewResult.stderr || viewResult.stdout}`
);
}
const existingPr = JSON.parse(viewResult.stdout);
if (existingPr.url) { if (existingPr.url) {
prUrl = existingPr.url; prUrl = existingPr.url;
prNumber = existingPr.number; prNumber = existingPr.number;
@@ -388,7 +396,7 @@ export function createCreatePRHandler() {
url: existingPr.url, url: existingPr.url,
title: existingPr.title || title, title: existingPr.title || title,
state: validatePRState(existingPr.state), state: validatePRState(existingPr.state),
createdAt: new Date().toISOString(), createdAt: existingPr.createdAt || new Date().toISOString(),
}); });
logger.debug(`Fetched and stored existing PR: #${existingPr.number}`); logger.debug(`Fetched and stored existing PR: #${existingPr.number}`);
} }

View File

@@ -30,6 +30,9 @@ import { runInitScript } from '../../../services/init-script-service.js';
const logger = createLogger('Worktree'); const logger = createLogger('Worktree');
/** Timeout for git fetch operations (30 seconds) */
const FETCH_TIMEOUT_MS = 30_000;
const execAsync = promisify(exec); const execAsync = promisify(exec);
/** /**
@@ -91,7 +94,7 @@ export function createCreateHandler(events: EventEmitter, settingsService?: Sett
const { projectPath, branchName, baseBranch } = req.body as { const { projectPath, branchName, baseBranch } = req.body as {
projectPath: string; projectPath: string;
branchName: string; branchName: string;
baseBranch?: string; // Optional base branch to create from (defaults to current HEAD) baseBranch?: string; // Optional base branch to create from (defaults to current HEAD). Can be a remote branch like "origin/main".
}; };
if (!projectPath || !branchName) { if (!projectPath || !branchName) {
@@ -171,6 +174,25 @@ export function createCreateHandler(events: EventEmitter, settingsService?: Sett
// Create worktrees directory if it doesn't exist // Create worktrees directory if it doesn't exist
await secureFs.mkdir(worktreesDir, { recursive: true }); await secureFs.mkdir(worktreesDir, { recursive: true });
// 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) // Check if branch exists (using array arguments to prevent injection)
let branchExists = false; let branchExists = false;
try { try {

View File

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

View File

@@ -213,8 +213,10 @@ export function createGenerateCommitMessageHandler(
} }
} }
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
// Use result if available (some providers return final text here) // Use result text if longer than accumulated text (consistent with simpleQuery pattern)
responseText = msg.result; if (msg.result.length > responseText.length) {
responseText = msg.result;
}
} }
} }

View File

@@ -30,6 +30,8 @@ const MAX_DIFF_SIZE = 15_000;
const PR_DESCRIPTION_SYSTEM_PROMPT = `You are a pull request description generator. Your task is to create a clear, well-structured PR title and description based on the git diff and branch information provided. const PR_DESCRIPTION_SYSTEM_PROMPT = `You are a pull request description generator. Your task is to create a clear, well-structured PR title and description based on the git diff and branch information provided.
IMPORTANT: Do NOT include any conversational text, explanations, or preamble. Do NOT say things like "I'll analyze..." or "Here is...". Output ONLY the structured format below and nothing else.
Output your response in EXACTLY this format (including the markers): Output your response in EXACTLY this format (including the markers):
---TITLE--- ---TITLE---
<a concise PR title, 50-72 chars, imperative mood> <a concise PR title, 50-72 chars, imperative mood>
@@ -41,6 +43,7 @@ Output your response in EXACTLY this format (including the markers):
<Detailed list of what was changed and why> <Detailed list of what was changed and why>
Rules: Rules:
- Your ENTIRE response must start with ---TITLE--- and contain nothing before it
- The title should be concise and descriptive (50-72 characters) - The title should be concise and descriptive (50-72 characters)
- Use imperative mood for the title (e.g., "Add dark mode toggle" not "Added dark mode toggle") - Use imperative mood for the title (e.g., "Add dark mode toggle" not "Added dark mode toggle")
- The description should explain WHAT changed and WHY - The description should explain WHAT changed and WHY
@@ -397,7 +400,10 @@ export function createGeneratePRDescriptionHandler(
} }
} }
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
responseText = msg.result; // Use result text if longer than accumulated text (consistent with simpleQuery pattern)
if (msg.result.length > responseText.length) {
responseText = msg.result;
}
} }
} }
@@ -413,7 +419,9 @@ export function createGeneratePRDescriptionHandler(
return; return;
} }
// Parse the response to extract title and body // Parse the response to extract title and body.
// The model may include conversational preamble before the structured markers,
// so we search for the markers anywhere in the response, not just at the start.
let title = ''; let title = '';
let body = ''; let body = '';
@@ -424,14 +432,46 @@ export function createGeneratePRDescriptionHandler(
title = titleMatch[1].trim(); title = titleMatch[1].trim();
body = bodyMatch[1].trim(); body = bodyMatch[1].trim();
} else { } else {
// Fallback: treat first line as title, rest as body // Fallback: try to extract meaningful content, skipping any conversational preamble.
const lines = fullResponse.split('\n'); // Common preamble patterns start with "I'll", "I will", "Here", "Let me", "Based on", etc.
title = lines[0].trim(); const lines = fullResponse.split('\n').filter((line) => line.trim().length > 0);
body = lines.slice(1).join('\n').trim();
// Skip lines that look like conversational preamble
let startIndex = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Check if this line looks like conversational AI preamble
if (
/^(I'll|I will|Here('s| is| are)|Let me|Based on|Looking at|Analyzing|Sure|OK|Okay|Of course)/i.test(
line
) ||
/^(The following|Below is|This (is|will)|After (analyzing|reviewing|looking))/i.test(
line
)
) {
startIndex = i + 1;
continue;
}
break;
}
// Use remaining lines after skipping preamble
const contentLines = lines.slice(startIndex);
if (contentLines.length > 0) {
title = contentLines[0].trim();
body = contentLines.slice(1).join('\n').trim();
} else {
// If all lines were filtered as preamble, use the original first non-empty line
title = lines[0]?.trim() || '';
body = lines.slice(1).join('\n').trim();
}
} }
// Clean up title - remove any markdown or quotes // Clean up title - remove any markdown headings, quotes, or marker artifacts
title = title.replace(/^#+\s*/, '').replace(/^["']|["']$/g, ''); title = title
.replace(/^#+\s*/, '')
.replace(/^["']|["']$/g, '')
.replace(/^---\w+---\s*/, '');
logger.info(`Generated PR title: ${title.substring(0, 100)}...`); logger.info(`Generated PR title: ${title.substring(0, 100)}...`);

View File

@@ -130,6 +130,7 @@ export function createListBranchesHandler() {
let aheadCount = 0; let aheadCount = 0;
let behindCount = 0; let behindCount = 0;
let hasRemoteBranch = false; let hasRemoteBranch = false;
let trackingRemote: string | undefined;
try { try {
// First check if there's a remote tracking branch // First check if there's a remote tracking branch
const { stdout: upstreamOutput } = await execFileAsync( const { stdout: upstreamOutput } = await execFileAsync(
@@ -138,8 +139,14 @@ export function createListBranchesHandler() {
{ cwd: worktreePath } { cwd: worktreePath }
); );
if (upstreamOutput.trim()) { const upstreamRef = upstreamOutput.trim();
if (upstreamRef) {
hasRemoteBranch = true; hasRemoteBranch = true;
// Extract the remote name from the upstream ref (e.g. "origin/main" -> "origin")
const slashIndex = upstreamRef.indexOf('/');
if (slashIndex !== -1) {
trackingRemote = upstreamRef.slice(0, slashIndex);
}
const { stdout: aheadBehindOutput } = await execFileAsync( const { stdout: aheadBehindOutput } = await execFileAsync(
'git', 'git',
['rev-list', '--left-right', '--count', `${currentBranch}@{upstream}...HEAD`], ['rev-list', '--left-right', '--count', `${currentBranch}@{upstream}...HEAD`],
@@ -174,6 +181,7 @@ export function createListBranchesHandler() {
behindCount, behindCount,
hasRemoteBranch, hasRemoteBranch,
hasAnyRemotes, hasAnyRemotes,
trackingRemote,
}, },
}); });
} catch (error) { } catch (error) {

View File

@@ -20,7 +20,12 @@ export function createMergeHandler(events: EventEmitter) {
branchName: string; branchName: string;
worktreePath: string; worktreePath: string;
targetBranch?: string; // Branch to merge into (defaults to 'main') targetBranch?: string; // Branch to merge into (defaults to 'main')
options?: { squash?: boolean; message?: string; deleteWorktreeAndBranch?: boolean }; options?: {
squash?: boolean;
message?: string;
deleteWorktreeAndBranch?: boolean;
remote?: string;
};
}; };
if (!projectPath || !branchName || !worktreePath) { if (!projectPath || !branchName || !worktreePath) {

View File

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

View File

@@ -14,17 +14,19 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import path from 'path'; import path from 'path';
import { getErrorMessage, logError, isValidBranchName } from '../common.js'; import { getErrorMessage, logError, isValidBranchName, isValidRemoteName } from '../common.js';
import type { EventEmitter } from '../../../lib/events.js'; import type { EventEmitter } from '../../../lib/events.js';
import { runRebase } from '../../../services/rebase-service.js'; import { runRebase } from '../../../services/rebase-service.js';
export function createRebaseHandler(events: EventEmitter) { export function createRebaseHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { worktreePath, ontoBranch } = req.body as { const { worktreePath, ontoBranch, remote } = req.body as {
worktreePath: string; worktreePath: string;
/** The branch/ref to rebase onto (e.g., 'origin/main', 'main') */ /** The branch/ref to rebase onto (e.g., 'origin/main', 'main') */
ontoBranch: string; ontoBranch: string;
/** Remote name to fetch from before rebasing (defaults to 'origin') */
remote?: string;
}; };
if (!worktreePath) { if (!worktreePath) {
@@ -55,6 +57,15 @@ export function createRebaseHandler(events: EventEmitter) {
return; return;
} }
// Validate optional remote name to reject unsafe characters at the route layer
if (remote !== undefined && !isValidRemoteName(remote)) {
res.status(400).json({
success: false,
error: `Invalid remote name: "${remote}"`,
});
return;
}
// Emit started event // Emit started event
events.emit('rebase:started', { events.emit('rebase:started', {
worktreePath: resolvedWorktreePath, worktreePath: resolvedWorktreePath,
@@ -62,7 +73,7 @@ export function createRebaseHandler(events: EventEmitter) {
}); });
// Execute the rebase via the service // Execute the rebase via the service
const result = await runRebase(resolvedWorktreePath, ontoBranch); const result = await runRebase(resolvedWorktreePath, ontoBranch, { remote });
if (result.success) { if (result.success) {
// Emit success event // Emit success event

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -168,6 +168,20 @@ ${feature.spec}
feature = await this.loadFeatureFn(projectPath, featureId); feature = await this.loadFeatureFn(projectPath, featureId);
if (!feature) throw new Error(`Feature ${featureId} not found`); if (!feature) throw new Error(`Feature ${featureId} not found`);
// Update status to in_progress immediately after acquiring the feature.
// This prevents a race condition where the UI reloads features and sees the
// feature still in 'backlog' status while it's actually being executed.
// Only do this for the initial call (not internal/recursive calls which would
// redundantly update the status).
if (
!options?._calledInternally &&
(feature.status === 'backlog' ||
feature.status === 'ready' ||
feature.status === 'interrupted')
) {
await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress');
}
if (!options?.continuationPrompt) { if (!options?.continuationPrompt) {
if (feature.planSpec?.status === 'approved') { if (feature.planSpec?.status === 'approved') {
const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]'); const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]');
@@ -199,7 +213,18 @@ ${feature.spec}
validateWorkingDirectory(workDir); validateWorkingDirectory(workDir);
tempRunningFeature.worktreePath = worktreePath; tempRunningFeature.worktreePath = worktreePath;
tempRunningFeature.branchName = branchName ?? null; tempRunningFeature.branchName = branchName ?? null;
await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress'); // Ensure status is in_progress (may already be set from the early update above,
// but internal/recursive calls skip the early update and need it here).
// Mirror the external guard: only transition when the feature is still in
// backlog, ready, or interrupted to avoid overwriting a concurrent terminal status.
if (
options?._calledInternally &&
(feature.status === 'backlog' ||
feature.status === 'ready' ||
feature.status === 'interrupted')
) {
await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress');
}
this.eventBus.emitAutoModeEvent('auto_mode_feature_start', { this.eventBus.emitAutoModeEvent('auto_mode_feature_start', {
featureId, featureId,
projectPath, projectPath,

View File

@@ -225,6 +225,14 @@ export class FeatureLoader {
return null; return null;
} }
// Clear transient runtime flag - titleGenerating is only meaningful during
// the current session's async title generation. If it was persisted (e.g.,
// app closed before generation completed), it would cause the UI to show
// "Generating title..." indefinitely.
if (feature.titleGenerating) {
delete feature.titleGenerating;
}
return feature; return feature;
}); });
@@ -323,7 +331,14 @@ export class FeatureLoader {
logRecoveryWarning(result, `Feature ${featureId}`, logger); logRecoveryWarning(result, `Feature ${featureId}`, logger);
return result.data; const feature = result.data;
// Clear transient runtime flag (same as in getAll)
if (feature?.titleGenerating) {
delete feature.titleGenerating;
}
return feature;
} }
/** /**
@@ -367,8 +382,15 @@ export class FeatureLoader {
descriptionHistory: initialHistory, descriptionHistory: initialHistory,
}; };
// Remove transient runtime fields before persisting to disk.
// titleGenerating is UI-only state that tracks in-flight async title generation.
// Persisting it can cause cards to show "Generating title..." indefinitely
// if the app restarts before generation completes.
const featureToWrite = { ...feature };
delete featureToWrite.titleGenerating;
// Write feature.json atomically with backup support // Write feature.json atomically with backup support
await atomicWriteJson(featureJsonPath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); await atomicWriteJson(featureJsonPath, featureToWrite, { backupCount: DEFAULT_BACKUP_COUNT });
logger.info(`Created feature ${featureId}`); logger.info(`Created feature ${featureId}`);
return feature; return feature;
@@ -452,9 +474,13 @@ export class FeatureLoader {
descriptionHistory: updatedHistory, descriptionHistory: updatedHistory,
}; };
// Remove transient runtime fields before persisting (same as create)
const featureToWrite = { ...updatedFeature };
delete featureToWrite.titleGenerating;
// Write back to file atomically with backup support // Write back to file atomically with backup support
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
await atomicWriteJson(featureJsonPath, updatedFeature, { backupCount: DEFAULT_BACKUP_COUNT }); await atomicWriteJson(featureJsonPath, featureToWrite, { backupCount: DEFAULT_BACKUP_COUNT });
logger.info(`Updated feature ${featureId}`); logger.info(`Updated feature ${featureId}`);
return updatedFeature; return updatedFeature;

View File

@@ -4,7 +4,7 @@
* Extracted from worktree merge route to allow internal service calls. * Extracted from worktree merge route to allow internal service calls.
*/ */
import { createLogger, isValidBranchName } from '@automaker/utils'; import { createLogger, isValidBranchName, isValidRemoteName } from '@automaker/utils';
import { type EventEmitter } from '../lib/events.js'; import { type EventEmitter } from '../lib/events.js';
import { execGitCommand } from '@automaker/git-utils'; import { execGitCommand } from '@automaker/git-utils';
const logger = createLogger('MergeService'); const logger = createLogger('MergeService');
@@ -13,6 +13,8 @@ export interface MergeOptions {
squash?: boolean; squash?: boolean;
message?: string; message?: string;
deleteWorktreeAndBranch?: boolean; deleteWorktreeAndBranch?: boolean;
/** Remote name to fetch from before merging (defaults to 'origin') */
remote?: string;
} }
export interface MergeServiceResult { export interface MergeServiceResult {
@@ -35,7 +37,11 @@ export interface MergeServiceResult {
* @param branchName - Source branch to merge * @param branchName - Source branch to merge
* @param worktreePath - Path to the worktree (used for deletion if requested) * @param worktreePath - Path to the worktree (used for deletion if requested)
* @param targetBranch - Branch to merge into (defaults to 'main') * @param targetBranch - Branch to merge into (defaults to 'main')
* @param options - Merge options (squash, message, deleteWorktreeAndBranch) * @param options - Merge options
* @param options.squash - If true, perform a squash merge
* @param options.message - Custom merge commit message
* @param options.deleteWorktreeAndBranch - If true, delete worktree and branch after merge
* @param options.remote - Remote name to fetch from before merging (defaults to 'origin')
*/ */
export async function performMerge( export async function performMerge(
projectPath: string, projectPath: string,
@@ -88,6 +94,33 @@ export async function performMerge(
}; };
} }
// Validate the remote name to prevent git option injection.
// Reject invalid remote names so the caller knows their input was wrong,
// consistent with how invalid branch names are handled above.
const remote = options?.remote || 'origin';
if (!isValidRemoteName(remote)) {
logger.warn('Invalid remote name supplied to merge-service', {
remote,
projectPath,
});
return {
success: false,
error: `Invalid remote name: "${remote}"`,
};
}
// Fetch latest from remote before merging to ensure we have up-to-date refs
try {
await execGitCommand(['fetch', remote], projectPath);
} catch (fetchError) {
logger.warn('Failed to fetch from remote before merge; proceeding with local refs', {
remote,
projectPath,
error: (fetchError as Error).message,
});
// Non-fatal: proceed with local refs if fetch fails (e.g. offline)
}
// Emit merge:start after validating inputs // Emit merge:start after validating inputs
emitter?.emit('merge:start', { branchName, targetBranch: mergeTo, worktreePath }); emitter?.emit('merge:start', { branchName, targetBranch: mergeTo, worktreePath });

View File

@@ -0,0 +1,225 @@
/**
* Service for resolving PR target information from git remotes.
*
* Extracts remote-parsing and target-resolution logic that was previously
* inline in the create-pr route handler.
*/
// TODO: Move execAsync/execEnv to a shared lib (lib/exec.ts or @automaker/utils) so that
// services no longer depend on route internals. Tracking issue: route-to-service dependency
// inversion. For now, a local thin wrapper is used within the service boundary.
import { exec } from 'child_process';
import { promisify } from 'util';
import { createLogger, isValidRemoteName } from '@automaker/utils';
// Thin local wrapper — duplicates the route-level execAsync/execEnv until a
// shared lib/exec.ts (or @automaker/utils export) is created.
const execAsync = promisify(exec);
const pathSeparator = process.platform === 'win32' ? ';' : ':';
const _additionalPaths: string[] = [];
if (process.platform === 'win32') {
if (process.env.LOCALAPPDATA)
_additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
if (process.env.PROGRAMFILES) _additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
if (process.env['ProgramFiles(x86)'])
_additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`);
} else {
_additionalPaths.push(
'/opt/homebrew/bin',
'/usr/local/bin',
'/home/linuxbrew/.linuxbrew/bin',
`${process.env.HOME}/.local/bin`
);
}
const execEnv = {
...process.env,
PATH: [process.env.PATH, ..._additionalPaths.filter(Boolean)].filter(Boolean).join(pathSeparator),
};
const logger = createLogger('PRService');
export interface ParsedRemote {
owner: string;
repo: string;
}
export interface PrTargetResult {
repoUrl: string | null;
targetRepo: string | null;
pushOwner: string | null;
upstreamRepo: string | null;
originOwner: string | null;
parsedRemotes: Map<string, ParsedRemote>;
}
/**
* Parse all git remotes for the given repo path and resolve the PR target.
*
* @param worktreePath - Working directory of the repository / worktree
* @param pushRemote - Remote used for pushing (e.g. "origin")
* @param targetRemote - Explicit remote to target the PR against (optional)
*
* @throws {Error} When targetRemote is specified but not found among repository remotes
* @throws {Error} When pushRemote is not found among parsed remotes (when targetRemote is specified)
*/
export async function resolvePrTarget({
worktreePath,
pushRemote,
targetRemote,
}: {
worktreePath: string;
pushRemote: string;
targetRemote?: string;
}): Promise<PrTargetResult> {
// Validate remote names — pushRemote is a required string so the undefined
// guard is unnecessary, but targetRemote is optional.
if (!isValidRemoteName(pushRemote)) {
throw new Error(`Invalid push remote name: "${pushRemote}"`);
}
if (targetRemote !== undefined && !isValidRemoteName(targetRemote)) {
throw new Error(`Invalid target remote name: "${targetRemote}"`);
}
let repoUrl: string | null = null;
let upstreamRepo: string | null = null;
let originOwner: string | null = null;
const parsedRemotes: Map<string, ParsedRemote> = new Map();
try {
const { stdout: remotes } = await execAsync('git remote -v', {
cwd: worktreePath,
env: execEnv,
});
// Parse remotes to detect fork workflow and get repo URL
const lines = remotes.split(/\r?\n/); // Handle both Unix and Windows line endings
for (const line of lines) {
// Try multiple patterns to match different remote URL formats
// Pattern 1: git@github.com:owner/repo.git (fetch)
// Pattern 2: https://github.com/owner/repo.git (fetch)
// Pattern 3: https://github.com/owner/repo (fetch)
let match = line.match(
/^([a-zA-Z0-9._-]+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/
);
if (!match) {
// Try SSH format: git@github.com:owner/repo.git
match = line.match(
/^([a-zA-Z0-9._-]+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/
);
}
if (!match) {
// Try HTTPS format: https://github.com/owner/repo.git
match = line.match(
/^([a-zA-Z0-9._-]+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/
);
}
if (match) {
const [, remoteName, owner, repo] = match;
parsedRemotes.set(remoteName, { owner, repo });
if (remoteName === 'upstream') {
upstreamRepo = `${owner}/${repo}`;
repoUrl = `https://github.com/${owner}/${repo}`;
} else if (remoteName === 'origin') {
originOwner = owner;
if (!repoUrl) {
repoUrl = `https://github.com/${owner}/${repo}`;
}
}
}
}
} catch (err) {
// Log the failure for debugging — control flow falls through to auto-detection
logger.debug('Failed to parse git remotes', { worktreePath, error: err });
}
// When targetRemote is explicitly provided but remote parsing failed entirely
// (parsedRemotes is empty), we cannot validate or resolve the requested remote.
// Silently proceeding to auto-detection would ignore the caller's explicit intent,
// so we fail fast with a clear error instead.
if (targetRemote && parsedRemotes.size === 0) {
throw new Error(
`targetRemote "${targetRemote}" was specified but no remotes could be parsed from the repository. ` +
`Ensure the repository has at least one configured remote (parsedRemotes is empty).`
);
}
// When a targetRemote is explicitly specified, validate that it is known
// before using it. Silently falling back to auto-detection when the caller
// explicitly requested a remote that doesn't exist is misleading, so we
// fail fast here instead.
if (targetRemote && parsedRemotes.size > 0 && !parsedRemotes.has(targetRemote)) {
throw new Error(`targetRemote "${targetRemote}" not found in repository remotes`);
}
// When a targetRemote is explicitly specified, override fork detection
// to use the specified remote as the PR target
let targetRepo: string | null = null;
let pushOwner: string | null = null;
if (targetRemote && parsedRemotes.size > 0) {
const targetInfo = parsedRemotes.get(targetRemote);
const pushInfo = parsedRemotes.get(pushRemote);
// If the push remote is not found in the parsed remotes, we cannot
// determine the push owner and would build incorrect URLs. Fail fast
// instead of silently proceeding with null values.
if (!pushInfo) {
logger.warn('Push remote not found in parsed remotes', {
pushRemote,
targetRemote,
availableRemotes: [...parsedRemotes.keys()],
});
throw new Error(`Push remote "${pushRemote}" not found in repository remotes`);
}
if (targetInfo) {
targetRepo = `${targetInfo.owner}/${targetInfo.repo}`;
repoUrl = `https://github.com/${targetInfo.owner}/${targetInfo.repo}`;
}
pushOwner = pushInfo.owner;
// Override the auto-detected upstream/origin with explicit targetRemote
// Only treat as cross-remote if target differs from push remote
if (targetRemote !== pushRemote && targetInfo) {
upstreamRepo = targetRepo;
originOwner = pushOwner;
} else if (targetInfo) {
// Same remote for push and target - regular (non-fork) workflow
upstreamRepo = null;
originOwner = targetInfo.owner;
repoUrl = `https://github.com/${targetInfo.owner}/${targetInfo.repo}`;
}
}
// Fallback: Try to get repo URL from git config if remote parsing failed
if (!repoUrl) {
try {
const { stdout: originUrl } = await execAsync('git config --get remote.origin.url', {
cwd: worktreePath,
env: execEnv,
});
const url = originUrl.trim();
// Parse URL to extract owner/repo
// Handle both SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git)
const match = url.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
if (match) {
const [, owner, repo] = match;
originOwner = owner;
repoUrl = `https://github.com/${owner}/${repo}`;
}
} catch {
// Failed to get repo URL from config
}
}
return {
repoUrl,
targetRepo,
pushOwner,
upstreamRepo,
originOwner,
parsedRemotes,
};
}

View File

@@ -46,6 +46,12 @@ export interface PullResult {
conflictSource?: 'pull' | 'stash'; conflictSource?: 'pull' | 'stash';
conflictFiles?: string[]; conflictFiles?: string[];
message?: string; message?: string;
/** Whether the pull resulted in a merge commit (not fast-forward) */
isMerge?: boolean;
/** Whether the pull was a fast-forward (no merge commit needed) */
isFastForward?: boolean;
/** Files affected by the merge (only present when isMerge is true) */
mergeAffectedFiles?: string[];
} }
// ============================================================================ // ============================================================================
@@ -178,6 +184,31 @@ function isConflictError(errorOutput: string): boolean {
return errorOutput.includes('CONFLICT') || errorOutput.includes('Automatic merge failed'); return errorOutput.includes('CONFLICT') || errorOutput.includes('Automatic merge failed');
} }
/**
* Determine whether the current HEAD commit is a merge commit by checking
* whether it has two or more parent hashes.
*
* Runs `git show -s --pretty=%P HEAD` which prints the parent SHAs separated
* by spaces. A merge commit has at least two parents; a regular commit has one.
*
* @param worktreePath - Path to the git worktree
* @returns true if HEAD is a merge commit, false otherwise
*/
async function isMergeCommit(worktreePath: string): Promise<boolean> {
try {
const output = await execGitCommand(['show', '-s', '--pretty=%P', 'HEAD'], worktreePath);
// Each parent SHA is separated by a space; two or more means it's a merge
const parents = output
.trim()
.split(/\s+/)
.filter((p) => p.length > 0);
return parents.length >= 2;
} catch {
// If the check fails for any reason, assume it is not a merge commit
return false;
}
}
/** /**
* Check whether an output string indicates a stash conflict. * Check whether an output string indicates a stash conflict.
*/ */
@@ -302,10 +333,39 @@ export async function performPull(
const pullArgs = upstreamStatus === 'tracking' ? ['pull'] : ['pull', targetRemote, branchName]; const pullArgs = upstreamStatus === 'tracking' ? ['pull'] : ['pull', targetRemote, branchName];
let pullConflict = false; let pullConflict = false;
let pullConflictFiles: string[] = []; let pullConflictFiles: string[] = [];
// Declare merge detection variables before the try block so they are accessible
// in the stash reapplication path even when didStash is true.
let isMerge = false;
let isFastForward = false;
let mergeAffectedFiles: string[] = [];
try { try {
const pullOutput = await execGitCommand(pullArgs, worktreePath); const pullOutput = await execGitCommand(pullArgs, worktreePath);
const alreadyUpToDate = pullOutput.includes('Already up to date'); const alreadyUpToDate = pullOutput.includes('Already up to date');
// Detect fast-forward from git pull output
isFastForward = pullOutput.includes('Fast-forward') || pullOutput.includes('fast-forward');
// Detect merge by checking whether the new HEAD has two parents (more reliable
// than string-matching localised pull output which may not contain 'Merge').
isMerge = !alreadyUpToDate && !isFastForward ? await isMergeCommit(worktreePath) : false;
// If it was a real merge (not fast-forward), get the affected files
if (isMerge) {
try {
// Get files changed in the merge commit
const diffOutput = await execGitCommand(
['diff', '--name-only', 'HEAD~1', 'HEAD'],
worktreePath
);
mergeAffectedFiles = diffOutput
.trim()
.split('\n')
.filter((f: string) => f.trim().length > 0);
} catch {
// Ignore errors - this is best-effort
}
}
// If no stash to reapply, return success // If no stash to reapply, return success
if (!didStash) { if (!didStash) {
@@ -317,6 +377,8 @@ export async function performPull(
stashed: false, stashed: false,
stashRestored: false, stashRestored: false,
message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes', message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes',
...(isMerge ? { isMerge: true, mergeAffectedFiles } : {}),
...(isFastForward ? { isFastForward: true } : {}),
}; };
} }
} catch (pullError: unknown) { } catch (pullError: unknown) {
@@ -374,7 +436,11 @@ export async function performPull(
// 10. Pull succeeded, now try to reapply stash // 10. Pull succeeded, now try to reapply stash
if (didStash) { if (didStash) {
return await reapplyStash(worktreePath, branchName); return await reapplyStash(worktreePath, branchName, {
isMerge,
isFastForward,
mergeAffectedFiles,
});
} }
// Shouldn't reach here, but return a safe default // Shouldn't reach here, but return a safe default
@@ -392,9 +458,21 @@ export async function performPull(
* *
* @param worktreePath - Path to the git worktree * @param worktreePath - Path to the git worktree
* @param branchName - Current branch name * @param branchName - Current branch name
* @param mergeInfo - Merge/fast-forward detection info from the pull step
* @returns PullResult reflecting stash reapplication status * @returns PullResult reflecting stash reapplication status
*/ */
async function reapplyStash(worktreePath: string, branchName: string): Promise<PullResult> { async function reapplyStash(
worktreePath: string,
branchName: string,
mergeInfo: { isMerge: boolean; isFastForward: boolean; mergeAffectedFiles: string[] }
): Promise<PullResult> {
const mergeFields: Partial<PullResult> = {
...(mergeInfo.isMerge
? { isMerge: true, mergeAffectedFiles: mergeInfo.mergeAffectedFiles }
: {}),
...(mergeInfo.isFastForward ? { isFastForward: true } : {}),
};
try { try {
await popStash(worktreePath); await popStash(worktreePath);
@@ -406,6 +484,7 @@ async function reapplyStash(worktreePath: string, branchName: string): Promise<P
hasConflicts: false, hasConflicts: false,
stashed: true, stashed: true,
stashRestored: true, stashRestored: true,
...mergeFields,
message: 'Pulled latest changes and restored your stashed changes.', message: 'Pulled latest changes and restored your stashed changes.',
}; };
} catch (stashPopError: unknown) { } catch (stashPopError: unknown) {
@@ -431,6 +510,7 @@ async function reapplyStash(worktreePath: string, branchName: string): Promise<P
conflictFiles: stashConflictFiles, conflictFiles: stashConflictFiles,
stashed: true, stashed: true,
stashRestored: false, stashRestored: false,
...mergeFields,
message: 'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.', message: 'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.',
}; };
} }
@@ -445,6 +525,7 @@ async function reapplyStash(worktreePath: string, branchName: string): Promise<P
hasConflicts: false, hasConflicts: false,
stashed: true, stashed: true,
stashRestored: false, stashRestored: false,
...mergeFields,
message: message:
'Pull succeeded but failed to reapply stashed changes. Your changes are still in the stash list.', 'Pull succeeded but failed to reapply stashed changes. Your changes are still in the stash list.',
}; };

View File

@@ -7,7 +7,7 @@
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import { createLogger, getErrorMessage } from '@automaker/utils'; import { createLogger, getErrorMessage, isValidRemoteName } from '@automaker/utils';
import { execGitCommand, getCurrentBranch, getConflictFiles } from '@automaker/git-utils'; import { execGitCommand, getCurrentBranch, getConflictFiles } from '@automaker/git-utils';
const logger = createLogger('RebaseService'); const logger = createLogger('RebaseService');
@@ -16,6 +16,11 @@ const logger = createLogger('RebaseService');
// Types // Types
// ============================================================================ // ============================================================================
export interface RebaseOptions {
/** Remote name to fetch from before rebasing (defaults to 'origin') */
remote?: string;
}
export interface RebaseResult { export interface RebaseResult {
success: boolean; success: boolean;
error?: string; error?: string;
@@ -36,9 +41,14 @@ export interface RebaseResult {
* *
* @param worktreePath - Path to the git worktree * @param worktreePath - Path to the git worktree
* @param ontoBranch - The branch to rebase onto (e.g., 'origin/main') * @param ontoBranch - The branch to rebase onto (e.g., 'origin/main')
* @param options - Optional rebase options (remote name for fetch)
* @returns RebaseResult with success/failure information * @returns RebaseResult with success/failure information
*/ */
export async function runRebase(worktreePath: string, ontoBranch: string): Promise<RebaseResult> { export async function runRebase(
worktreePath: string,
ontoBranch: string,
options?: RebaseOptions
): Promise<RebaseResult> {
// Reject empty, whitespace-only, or dash-prefixed branch names. // Reject empty, whitespace-only, or dash-prefixed branch names.
const normalizedOntoBranch = ontoBranch?.trim() ?? ''; const normalizedOntoBranch = ontoBranch?.trim() ?? '';
if (normalizedOntoBranch === '' || normalizedOntoBranch.startsWith('-')) { if (normalizedOntoBranch === '' || normalizedOntoBranch.startsWith('-')) {
@@ -59,6 +69,33 @@ export async function runRebase(worktreePath: string, ontoBranch: string): Promi
}; };
} }
// Validate the remote name to prevent git option injection.
// Reject invalid remote names so the caller knows their input was wrong,
// consistent with how invalid branch names are handled above.
const remote = options?.remote || 'origin';
if (!isValidRemoteName(remote)) {
logger.warn('Invalid remote name supplied to rebase-service', {
remote,
worktreePath,
});
return {
success: false,
error: `Invalid remote name: "${remote}"`,
};
}
// Fetch latest from remote before rebasing to ensure we have up-to-date refs
try {
await execGitCommand(['fetch', remote], worktreePath);
} catch (fetchError) {
logger.warn('Failed to fetch from remote before rebase; proceeding with local refs', {
remote,
worktreePath,
error: getErrorMessage(fetchError),
});
// Non-fatal: proceed with local refs if fetch fails (e.g. offline)
}
try { try {
// Pass ontoBranch after '--' so git treats it as a ref, not an option. // Pass ontoBranch after '--' so git treats it as a ref, not an option.
// Set LC_ALL=C so git always emits English output regardless of the system // Set LC_ALL=C so git always emits English output regardless of the system

View File

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

View File

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

View File

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

View File

@@ -486,7 +486,7 @@ describe('dev-server-service.ts', () => {
await service.startDevServer(testDir, testDir); await service.startDevServer(testDir, testDir);
// Simulate HTTPS dev server // Simulate HTTPS dev server
mockProcess.stdout.emit('data', Buffer.from('Server at https://localhost:3443\n')); mockProcess.stdout.emit('data', Buffer.from('Server listening at https://localhost:3443\n'));
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
@@ -521,6 +521,368 @@ describe('dev-server-service.ts', () => {
expect(serverInfo?.url).toBe(firstUrl); expect(serverInfo?.url).toBe(firstUrl);
expect(serverInfo?.url).toBe('http://localhost:5173/'); expect(serverInfo?.url).toBe('http://localhost:5173/');
}); });
it('should detect Astro format URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
// Astro uses the same "Local:" prefix as Vite
mockProcess.stdout.emit('data', Buffer.from(' 🚀 astro v4.0.0 started in 200ms\n'));
mockProcess.stdout.emit('data', Buffer.from(' ┃ Local http://localhost:4321/\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
// Astro doesn't use "Local:" with colon, so it should be caught by the localhost URL pattern
expect(serverInfo?.url).toBe('http://localhost:4321/');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect Remix format URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
mockProcess.stdout.emit(
'data',
Buffer.from('Remix App Server started at http://localhost:3000\n')
);
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:3000');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect Django format URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
mockProcess.stdout.emit(
'data',
Buffer.from('Starting development server at http://127.0.0.1:8000/\n')
);
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://127.0.0.1:8000/');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect Webpack Dev Server format URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
mockProcess.stdout.emit(
'data',
Buffer.from('<i> [webpack-dev-server] Project is running at http://localhost:8080/\n')
);
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:8080/');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect PHP built-in server format URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
mockProcess.stdout.emit(
'data',
Buffer.from('Development Server (http://localhost:8000) started\n')
);
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:8000');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect "listening on port" format (port-only detection)', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
// Some servers only print the port number, not a full URL
mockProcess.stdout.emit('data', Buffer.from('Server listening on port 4000\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:4000');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect "running on port" format (port-only detection)', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
mockProcess.stdout.emit('data', Buffer.from('Application running on port 9000\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:9000');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should strip ANSI escape codes before detecting URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
// Simulate Vite output with ANSI color codes
mockProcess.stdout.emit(
'data',
Buffer.from(
' \x1B[32m➜\x1B[0m \x1B[1mLocal:\x1B[0m \x1B[36mhttp://localhost:5173/\x1B[0m\n'
)
);
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:5173/');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should normalize 0.0.0.0 to localhost', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
mockProcess.stdout.emit('data', Buffer.from('Server listening at http://0.0.0.0:3000\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:3000');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should normalize [::] to localhost', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
mockProcess.stdout.emit('data', Buffer.from('Local: http://[::]:4000/\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:4000/');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should update port field when detected URL has different port', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
const result = await service.startDevServer(testDir, testDir);
const allocatedPort = result.result?.port;
// Server starts on a completely different port (ignoring PORT env var)
mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:9999/\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:9999/');
expect(serverInfo?.port).toBe(9999);
// The port should be different from what was initially allocated
if (allocatedPort !== 9999) {
expect(serverInfo?.port).not.toBe(allocatedPort);
}
});
it('should detect URL from stderr output', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
// Some servers output URL info to stderr
mockProcess.stderr.emit('data', Buffer.from('Local: http://localhost:3000/\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:3000/');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should not match URLs without a port (non-dev-server URLs)', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
const result = await service.startDevServer(testDir, testDir);
// CDN/external URLs should not be detected
mockProcess.stdout.emit(
'data',
Buffer.from('Downloading from https://cdn.example.com/bundle.js\n')
);
mockProcess.stdout.emit('data', Buffer.from('Fetching https://registry.npmjs.org/package\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
// Should keep the initial allocated URL since external URLs don't match
expect(serverInfo?.url).toBe(result.result?.url);
expect(serverInfo?.urlDetected).toBe(false);
});
it('should handle URLs with trailing punctuation', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
// URL followed by punctuation
mockProcess.stdout.emit('data', Buffer.from('Server started at http://localhost:3000.\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:3000');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect Express/Fastify format URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
mockProcess.stdout.emit('data', Buffer.from('Server listening on http://localhost:3000\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:3000');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect Angular CLI format URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
// Angular CLI output
mockProcess.stderr.emit(
'data',
Buffer.from(
'** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **\n'
)
);
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:4200/');
expect(serverInfo?.urlDetected).toBe(true);
});
}); });
}); });
@@ -531,6 +893,7 @@ function createMockProcess() {
mockProcess.stderr = new EventEmitter(); mockProcess.stderr = new EventEmitter();
mockProcess.kill = vi.fn(); mockProcess.kill = vi.fn();
mockProcess.killed = false; mockProcess.killed = false;
mockProcess.pid = 12345;
// Don't exit immediately - let the test control the lifecycle // Don't exit immediately - let the test control the lifecycle
return mockProcess; return mockProcess;

View File

@@ -53,6 +53,7 @@ const eslintConfig = defineConfig([
requestAnimationFrame: 'readonly', requestAnimationFrame: 'readonly',
cancelAnimationFrame: 'readonly', cancelAnimationFrame: 'readonly',
requestIdleCallback: 'readonly', requestIdleCallback: 'readonly',
cancelIdleCallback: 'readonly',
alert: 'readonly', alert: 'readonly',
// DOM Element Types // DOM Element Types
HTMLElement: 'readonly', HTMLElement: 'readonly',

View File

@@ -72,30 +72,169 @@
height: 100dvh; height: 100dvh;
overflow: hidden; overflow: hidden;
} }
/* Inline app shell: shows logo + spinner while JS bundle downloads.
On slow mobile networks the bundle can take 2-5s; this eliminates
the blank screen and gives immediate visual feedback.
React's createRoot().render() replaces #app's innerHTML, removing this. */
.app-shell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 24px;
opacity: 1;
}
.app-shell-logo {
width: 56px;
height: 56px;
opacity: 0.9;
}
/* Default (dark): light spinner + logo strokes */
.app-shell-spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.1);
border-top-color: rgba(255, 255, 255, 0.5);
border-radius: 50%;
animation: shell-spin 0.8s linear infinite;
}
.app-shell-logo-stroke {
stroke: rgba(255, 255, 255, 0.7);
}
.app-shell-logo-bg {
fill: rgba(255, 255, 255, 0.08);
}
/* Light themes: dark spinner + logo strokes.
The theme script below sets data-theme-type="light" on <html> for any light
theme, so future theme additions only need to update the script — not CSS. */
html[data-theme-type='light'] .app-shell-spinner {
border-color: rgba(0, 0, 0, 0.08);
border-top-color: rgba(0, 0, 0, 0.4);
}
html[data-theme-type='light'] .app-shell-logo-stroke {
stroke: rgba(0, 0, 0, 0.55);
}
html[data-theme-type='light'] .app-shell-logo-bg {
fill: rgba(0, 0, 0, 0.06);
}
/* System light preference when no theme class is applied yet */
@media (prefers-color-scheme: light) {
html:not([class]) .app-shell-spinner {
border-color: rgba(0, 0, 0, 0.08);
border-top-color: rgba(0, 0, 0, 0.4);
}
html:not([class]) .app-shell-logo-stroke {
stroke: rgba(0, 0, 0, 0.55);
}
html:not([class]) .app-shell-logo-bg {
fill: rgba(0, 0, 0, 0.06);
}
}
@keyframes shell-spin {
to {
transform: rotate(360deg);
}
}
</style> </style>
<script> <script>
// Prevent theme flash - apply stored theme before React hydrates // Prevent theme flash - apply stored theme before React hydrates
(function () { (function () {
try { try {
var stored = localStorage.getItem('automaker-storage'); // Primary key used by current builds for pre-React theme persistence.
if (stored) { var theme = localStorage.getItem('automaker:theme');
var data = JSON.parse(stored);
var theme = data.state?.theme; // Backward compatibility: older builds stored theme in the Zustand blob.
if (theme && theme !== 'system' && theme !== 'light') { if (!theme) {
document.documentElement.classList.add(theme); var stored = localStorage.getItem('automaker-storage');
} else if ( if (stored) {
theme === 'system' && var data = JSON.parse(stored);
window.matchMedia('(prefers-color-scheme: dark)').matches theme = data?.state?.theme || data?.theme || null;
) {
document.documentElement.classList.add('dark');
} }
} }
// Light theme names — kept in sync with the background-color rule above.
// Adding a new light theme only requires updating this array.
var lightThemes = [
'light',
'cream',
'solarizedlight',
'github',
'paper',
'rose',
'mint',
'lavender',
'sand',
'sky',
'peach',
'snow',
'sepia',
'gruvboxlight',
'nordlight',
'blossom',
'ayu-light',
'onelight',
'bluloco',
'feather',
];
if (theme && theme !== 'system') {
// Apply the stored theme class directly (covers 'light', 'dark', and
// all named themes like 'cream', 'nord', etc.)
document.documentElement.classList.add(theme);
if (lightThemes.indexOf(theme) !== -1) {
document.documentElement.setAttribute('data-theme-type', 'light');
}
} else if (
theme === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
document.documentElement.classList.add('dark');
} else if (
theme === 'system' &&
window.matchMedia('(prefers-color-scheme: light)').matches
) {
document.documentElement.setAttribute('data-theme-type', 'light');
}
// Detect PWA standalone mode early so CSS can apply reduced bottom safe-area
// before first paint, preventing a layout shift on notched devices.
if (
window.matchMedia('(display-mode: standalone)').matches ||
navigator.standalone === true
) {
document.documentElement.setAttribute('data-pwa', 'standalone');
}
} catch (e) {} } catch (e) {}
})(); })();
</script> </script>
</head> </head>
<body class="antialiased"> <body class="antialiased">
<div id="app"></div> <div id="app">
<!-- Inline app shell: renders instantly while JS downloads on slow mobile networks.
React's createRoot().render() replaces this content automatically. -->
<div class="app-shell">
<svg
class="app-shell-logo"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<rect class="app-shell-logo-bg" x="16" y="16" width="224" height="224" rx="56" />
<g
class="app-shell-logo-stroke"
fill="none"
stroke-width="20"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
<div class="app-shell-spinner" role="status" aria-label="Loading…"></div>
</div>
</div>
<script type="module" src="/src/renderer.tsx"></script> <script type="module" src="/src/renderer.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -42,10 +42,24 @@
"@automaker/dependency-resolver": "1.0.0", "@automaker/dependency-resolver": "1.0.0",
"@automaker/spec-parser": "1.0.0", "@automaker/spec-parser": "1.0.0",
"@automaker/types": "1.0.0", "@automaker/types": "1.0.0",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-xml": "6.1.0", "@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "^6.12.1", "@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2", "@codemirror/legacy-modes": "^6.5.2",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "6.1.3", "@codemirror/theme-one-dark": "6.1.3",
"@codemirror/view": "^6.39.15",
"@dnd-kit/core": "6.3.1", "@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0", "@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2", "@dnd-kit/utilities": "3.2.2",

View File

@@ -1,8 +1,8 @@
// Automaker Service Worker - Optimized for mobile PWA loading performance // Automaker Service Worker - Optimized for mobile PWA loading performance
// NOTE: CACHE_NAME is injected with a build hash at build time by the swCacheBuster // NOTE: CACHE_NAME is injected with a build hash at build time by the swCacheBuster
// Vite plugin (see vite.config.mts). In development it stays as-is; in production // Vite plugin (see vite.config.mts). In development it stays as-is; in production
// builds it becomes e.g. 'automaker-v3-a1b2c3d4' for automatic cache invalidation. // builds it becomes e.g. 'automaker-v5-a1b2c3d4' for automatic cache invalidation.
const CACHE_NAME = 'automaker-v3'; // replaced at build time → 'automaker-v3-<hash>' const CACHE_NAME = 'automaker-v5'; // replaced at build time → 'automaker-v5-<hash>'
// Separate cache for immutable hashed assets (long-lived) // Separate cache for immutable hashed assets (long-lived)
const IMMUTABLE_CACHE = 'automaker-immutable-v2'; const IMMUTABLE_CACHE = 'automaker-immutable-v2';
@@ -13,6 +13,7 @@ const API_CACHE = 'automaker-api-v1';
// Assets to cache on install (app shell for instant loading) // Assets to cache on install (app shell for instant loading)
const SHELL_ASSETS = [ const SHELL_ASSETS = [
'/', '/',
'/index.html',
'/manifest.json', '/manifest.json',
'/logo.png', '/logo.png',
'/logo_larger.png', '/logo_larger.png',
@@ -20,6 +21,12 @@ const SHELL_ASSETS = [
'/favicon.ico', '/favicon.ico',
]; ];
// Critical JS/CSS assets extracted from index.html at build time by the swCacheBuster
// Vite plugin. Populated during production builds; empty in dev mode.
// These are precached on SW install so that PWA cold starts after memory eviction
// serve instantly from cache instead of requiring a full network download.
const CRITICAL_ASSETS = [];
// Whether mobile caching is enabled (set via message from main thread). // Whether mobile caching is enabled (set via message from main thread).
// Persisted to Cache Storage so it survives aggressive SW termination on mobile. // Persisted to Cache Storage so it survives aggressive SW termination on mobile.
let mobileMode = false; let mobileMode = false;
@@ -60,7 +67,10 @@ async function restoreMobileMode() {
} }
// Restore mobileMode immediately on SW startup // Restore mobileMode immediately on SW startup
restoreMobileMode(); // Keep a promise so fetch handlers can await restoration on cold SW starts.
// This prevents a race where early API requests run before mobileMode is loaded
// from Cache Storage, incorrectly falling back to network-first.
const mobileModeRestorePromise = restoreMobileMode();
// API endpoints that are safe to serve from stale cache on mobile. // API endpoints that are safe to serve from stale cache on mobile.
// These are GET-only, read-heavy endpoints where showing slightly stale data // These are GET-only, read-heavy endpoints where showing slightly stale data
@@ -121,13 +131,68 @@ async function addCacheTimestamp(response) {
} }
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
// Cache the app shell AND critical JS/CSS assets so the PWA loads instantly.
// SHELL_ASSETS go into CACHE_NAME (general cache), CRITICAL_ASSETS go into
// IMMUTABLE_CACHE (long-lived, content-hashed assets). This ensures that even
// the very first visit populates the immutable cache — previously, assets were
// only cached on fetch interception, but the SW isn't active during the first
// page load so nothing got cached until the second visit.
//
// self.skipWaiting() is NOT called here — activation is deferred until the main
// thread sends a SKIP_WAITING message to avoid disrupting a live page.
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME).then((cache) => { Promise.all([
return cache.addAll(SHELL_ASSETS); // Cache app shell (HTML, icons, manifest) using individual fetch+put instead of
}) // cache.addAll(). This is critical because cache.addAll() respects the server's
// Cache-Control response headers — if the server sends 'Cache-Control: no-store'
// (which Vite dev server does for index.html), addAll() silently skips caching
// and the pre-React loading spinner is never served from cache.
//
// cache.put() bypasses Cache-Control headers entirely, ensuring the app shell
// is always cached on install regardless of what the server sends. This is the
// correct approach for SW-managed caches where the SW (not HTTP headers) controls
// freshness via the activate event's cache cleanup and the navigation strategy's
// background revalidation.
caches.open(CACHE_NAME).then((cache) =>
Promise.all(
SHELL_ASSETS.map((url) =>
fetch(url)
.then((response) => {
if (response.ok) return cache.put(url, response);
})
.catch(() => {
// Individual asset fetch failure is non-fatal — the SW still activates
// and the next navigation will populate the cache via Strategy 3.
})
)
)
),
// Cache critical JS/CSS bundles (injected at build time by swCacheBuster).
// Uses individual fetch+put instead of cache.addAll() so a single asset
// failure doesn't prevent the rest from being cached.
//
// IMPORTANT: We fetch with { mode: 'cors' } because Vite's output HTML uses
// <script type="module" crossorigin> and <link rel="modulepreload" crossorigin>
// for these assets. The Cache API keys entries by URL + request mode, so a
// no-cors cached response won't match a cors-mode browser request. Fetching
// with cors mode here ensures the cached entries match what the browser requests.
CRITICAL_ASSETS.length > 0
? caches.open(IMMUTABLE_CACHE).then((cache) =>
Promise.all(
CRITICAL_ASSETS.map((url) =>
fetch(url, { mode: 'cors' })
.then((response) => {
if (response.ok) return cache.put(url, response);
})
.catch(() => {
// Individual asset fetch failure is non-fatal
})
)
)
)
: Promise.resolve(),
])
); );
// Activate immediately without waiting for existing clients
self.skipWaiting();
}); });
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
@@ -145,10 +210,23 @@ self.addEventListener('activate', (event) => {
// When enabled, the browser fires the navigation fetch in parallel with // When enabled, the browser fires the navigation fetch in parallel with
// service worker boot, eliminating the SW startup delay (~50-200ms on mobile). // service worker boot, eliminating the SW startup delay (~50-200ms on mobile).
self.registration.navigationPreload && self.registration.navigationPreload.enable(), self.registration.navigationPreload && self.registration.navigationPreload.enable(),
// Claim clients so this SW immediately controls all open pages.
//
// This is safe in all activation scenarios:
// 1. First install: No old SW exists — claiming is a no-op with no side effects.
// Critically, this lets the fetch handler intercept requests during the same
// visit that registered the SW, populating the immutable cache.
// 2. SKIP_WAITING from main thread: The page is freshly loaded, so claiming
// won't cause a visible flash (the SW was explicitly asked to take over).
// 3. Natural activation (all old-SW tabs closed): The new SW activates when
// no pages are using the old SW, so claiming controls only new navigations.
//
// Without clients.claim(), the SW's fetch handler would not intercept any
// requests until the next full navigation — meaning the first visit after
// install would not benefit from the cache-first asset strategy.
self.clients.claim(),
]) ])
); );
// Take control of all clients immediately
self.clients.claim();
}); });
/** /**
@@ -159,16 +237,33 @@ self.addEventListener('activate', (event) => {
function isImmutableAsset(url) { function isImmutableAsset(url) {
const path = url.pathname; const path = url.pathname;
// Match Vite's hashed asset pattern: /assets/<name>-<hash>.<ext> // Match Vite's hashed asset pattern: /assets/<name>-<hash>.<ext>
// This covers JS bundles, CSS, and font files that Vite outputs to /assets/
// with content hashes (e.g., /assets/font-inter-WC6UYoCP.js).
// Note: We intentionally do NOT cache all font files globally — only those
// under /assets/ (which are Vite-processed, content-hashed, and actively used).
// There are 639+ font files (~20MB total) across all font families; caching them
// all would push iOS toward its ~50MB PWA quota and trigger eviction of everything.
if (path.startsWith('/assets/') && /-[A-Za-z0-9_-]{6,}\.\w+$/.test(path)) { if (path.startsWith('/assets/') && /-[A-Za-z0-9_-]{6,}\.\w+$/.test(path)) {
return true; return true;
} }
// Font files are immutable (woff2, woff, ttf, otf)
if (/\.(woff2?|ttf|otf)$/.test(path)) {
return true;
}
return false; return false;
} }
/**
* Determine if a request is for app code (JS/CSS) that should be cached aggressively.
* This includes both production /assets/* bundles and development /src/* modules.
*
* The path.startsWith('/src/') check is dev-only — in development the Vite dev server
* serves source files directly from /src/*. In production all code is bundled under
* /assets/*, so the /src/ check is harmless but only present for developer convenience.
*/
function isCodeAsset(url) {
const path = url.pathname;
const isScriptOrStyle = /\.(m?js|css|tsx?)$/.test(path);
if (!isScriptOrStyle) return false;
return path.startsWith('/assets/') || path.startsWith('/src/');
}
/** /**
* Determine if a URL points to a static asset that benefits from stale-while-revalidate * Determine if a URL points to a static asset that benefits from stale-while-revalidate
*/ */
@@ -203,69 +298,95 @@ self.addEventListener('fetch', (event) => {
// The main thread's React Query layer handles the eventual fresh data via its // The main thread's React Query layer handles the eventual fresh data via its
// own refetching mechanism, so the user sees updates within seconds. // own refetching mechanism, so the user sees updates within seconds.
if (url.pathname.startsWith('/api/')) { if (url.pathname.startsWith('/api/')) {
if (mobileMode && isCacheableApiRequest(url)) { event.respondWith(
event.respondWith( (async () => {
(async () => { // On mobile, service workers are frequently terminated and restarted.
const cache = await caches.open(API_CACHE); // Ensure persisted mobileMode is restored before deciding strategy so the
const cachedResponse = await cache.match(event.request); // very first API requests after restart can hit cache immediately.
try {
await mobileModeRestorePromise;
} catch (_e) {
// Best-effort restore — keep default mobileMode value on failure.
}
// Helper: start a network fetch that updates the cache on success. if (!(mobileMode && isCacheableApiRequest(url))) {
// Lazily invoked so we don't fire a network request when the cache // Non-mobile or non-cacheable API: skip SW caching and use network.
// is already fresh — saves bandwidth and battery on mobile. return fetch(event.request);
const startNetworkFetch = () => }
fetch(event.request)
.then(async (networkResponse) => { const cache = await caches.open(API_CACHE);
if (networkResponse.ok) { const cachedResponse = await cache.match(event.request);
// Store with timestamp for freshness checking
const timestampedResponse = await addCacheTimestamp(networkResponse); // Helper: start a network fetch that updates the cache on success.
cache.put(event.request, timestampedResponse); // Lazily invoked so we don't fire a network request when the cache
} // is already fresh — saves bandwidth and battery on mobile.
const startNetworkFetch = () =>
fetch(event.request)
.then(async (networkResponse) => {
if (networkResponse.ok) {
// Store with timestamp for freshness checking
const timestampedResponse = await addCacheTimestamp(networkResponse);
cache.put(event.request, timestampedResponse);
return networkResponse; return networkResponse;
}) }
.catch((err) => { // Non-ok response (e.g. 5xx) — don't resolve with it for the race
// Network failed - if we have cache, that's fine (returned below) // so the caller falls back to cachedResponse instead of showing an error page.
// If no cache, propagate the error if (cachedResponse) return null;
if (cachedResponse) return null; return networkResponse;
throw err; })
}); .catch((err) => {
// Network failed - if we have cache, that's fine (returned below)
// If no cache, propagate the error
if (cachedResponse) return null;
throw err;
});
// If we have a fresh-enough cached response, return it immediately // If we have a fresh-enough cached response, return it immediately
// without firing a background fetch — React Query's own refetching // without firing a background fetch — React Query's own refetching
// will request fresh data when its stale time expires. // will request fresh data when its stale time expires.
if (cachedResponse && isApiCacheFresh(cachedResponse)) { if (cachedResponse && isApiCacheFresh(cachedResponse)) {
return cachedResponse; return cachedResponse;
}
// From here the cache is either stale or missing — start the network fetch.
const fetchPromise = startNetworkFetch();
// If we have a stale cached response but network is slow, race them:
// Return whichever resolves first (cached immediately vs network)
if (cachedResponse) {
// Give network a brief window (2s) to respond, otherwise use stale cache
const networkResult = await Promise.race([
fetchPromise,
new Promise((resolve) => setTimeout(() => resolve(null), 2000)),
]);
if (!networkResult) {
// Timeout won — keep the background fetch alive so the cache update
// can complete even after we return the stale cached response.
event.waitUntil(fetchPromise.catch(() => {}));
} }
return networkResult || cachedResponse;
}
// From here the cache is either stale or missing — start the network fetch. // No cache at all - must wait for network
const fetchPromise = startNetworkFetch(); return fetchPromise;
})()
// If we have a stale cached response but network is slow, race them: );
// Return whichever resolves first (cached immediately vs network)
if (cachedResponse) {
// Give network a brief window (2s) to respond, otherwise use stale cache
const networkResult = await Promise.race([
fetchPromise,
new Promise((resolve) => setTimeout(() => resolve(null), 2000)),
]);
return networkResult || cachedResponse;
}
// No cache at all - must wait for network
return fetchPromise;
})()
);
return;
}
// Non-mobile or non-cacheable API: skip SW, let browser handle normally
return; return;
} }
// Strategy 1: Cache-first for immutable hashed assets (JS/CSS bundles, fonts) // Strategy 1: Cache-first for immutable hashed assets (JS/CSS bundles, fonts)
// These files contain content hashes in their names - they never change. // These files contain content hashes in their names - they never change.
//
// Uses { ignoreVary: true } for cache matching because the same asset URL
// can be requested with different modes: <link rel="prefetch"> uses no-cors,
// <script type="module" crossorigin> and <link rel="modulepreload" crossorigin>
// use cors. Without ignoreVary, a cors-mode browser request won't match a
// no-cors cached entry (or vice versa), causing unnecessary network fetches
// even when the asset is already in the cache.
if (isImmutableAsset(url)) { if (isImmutableAsset(url)) {
event.respondWith( event.respondWith(
caches.open(IMMUTABLE_CACHE).then((cache) => { caches.open(IMMUTABLE_CACHE).then((cache) => {
return cache.match(event.request).then((cachedResponse) => { return cache.match(event.request, { ignoreVary: true }).then((cachedResponse) => {
if (cachedResponse) { if (cachedResponse) {
return cachedResponse; return cachedResponse;
} }
@@ -281,6 +402,59 @@ self.addEventListener('fetch', (event) => {
return; return;
} }
// Strategy 1b: Cache-first for app code assets that are not immutable-hashed.
// This removes network-coupled startup delays for pre-React boot files when
// they are served without content hashes (for example, dev-like module paths).
if (isCodeAsset(url)) {
event.respondWith(
caches.open(CACHE_NAME).then((cache) =>
cache.match(event.request).then((cachedResponse) => {
const fetchPromise = fetch(event.request)
.then((networkResponse) => {
if (networkResponse.ok) {
cache.put(event.request, networkResponse.clone());
}
return networkResponse;
})
.catch(() => {
if (cachedResponse) return cachedResponse;
// Return a safe no-op response matching the asset type so the browser
// can parse it without errors, instead of a plain-text 503.
const dest = event.request.destination;
const urlPath = url.pathname;
if (dest === 'script' || urlPath.endsWith('.js') || urlPath.endsWith('.mjs')) {
return new Response('// offline', {
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'application/javascript' },
});
}
if (dest === 'style' || urlPath.endsWith('.css')) {
return new Response('/* offline */', {
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'text/css' },
});
}
return new Response('Service Unavailable', {
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'text/plain' },
});
});
if (cachedResponse) {
event.waitUntil(fetchPromise.catch(() => {}));
return cachedResponse;
}
return fetchPromise;
})
)
);
return;
}
// Strategy 2: Stale-while-revalidate for static assets (images, audio) // Strategy 2: Stale-while-revalidate for static assets (images, audio)
// Serve cached version immediately, update cache in background. // Serve cached version immediately, update cache in background.
if (isStaticAsset(url)) { if (isStaticAsset(url)) {
@@ -304,43 +478,55 @@ self.addEventListener('fetch', (event) => {
return; return;
} }
// Strategy 3: Network-first for navigation requests (HTML) // Strategy 3: Cache-first with background revalidation for navigation requests (HTML)
// Uses Navigation Preload when available - the browser fires the network request //
// in parallel with SW startup, eliminating the ~50-200ms SW boot delay on mobile. // The app shell (index.html) is a thin SPA entry point — its content rarely changes
// Falls back to regular fetch when Navigation Preload is not supported. // meaningfully between deploys because all JS/CSS bundles are content-hashed. Serving
// it from cache first eliminates the visible "reload flash" that occurs when the user
// switches back to the PWA and the old network-first strategy went to the network.
//
// The background revalidation ensures the cache stays fresh for the NEXT navigation,
// so new deployments are picked up within one page visit. Navigation Preload is used
// for the background fetch when available (no extra latency cost).
if (isNavigationRequest(event.request)) { if (isNavigationRequest(event.request)) {
event.respondWith( event.respondWith(
(async () => { (async () => {
try { const cache = await caches.open(CACHE_NAME);
// Use the preloaded response if available (fired during SW boot) const cachedResponse = (await cache.match(event.request)) || (await cache.match('/'));
// This is the key mobile performance win - no waiting for SW to start
const preloadResponse = event.preloadResponse && (await event.preloadResponse);
if (preloadResponse) {
// Cache the preloaded response for offline use
if (preloadResponse.ok && preloadResponse.type === 'basic') {
const clone = preloadResponse.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return preloadResponse;
}
// Fallback to regular fetch if Navigation Preload is not available // Start a background fetch to update the cache for next time.
const response = await fetch(event.request); // Uses Navigation Preload if available (already in-flight, no extra cost).
const updateCache = async () => {
try {
const preloadResponse = event.preloadResponse && (await event.preloadResponse);
const freshResponse = preloadResponse || (await fetch(event.request));
if (freshResponse.ok && freshResponse.type === 'basic') {
await cache.put(event.request, freshResponse.clone());
}
} catch (_e) {
// Network failed — cache stays as-is, still fine for next visit
}
};
if (cachedResponse) {
// Serve from cache immediately — no network delay, no reload flash.
// Update cache in background for the next visit.
event.waitUntil(updateCache());
return cachedResponse;
}
// No cache yet (first visit) — must go to network
try {
const preloadResponse = event.preloadResponse && (await event.preloadResponse);
const response = preloadResponse || (await fetch(event.request));
if (response.ok && response.type === 'basic') { if (response.ok && response.type === 'basic') {
const responseClone = response.clone(); // Use event.waitUntil to ensure the cache write completes before
caches.open(CACHE_NAME).then((cache) => { // the service worker is terminated (mirrors the cached-path pattern).
cache.put(event.request, responseClone); event.waitUntil(cache.put(event.request, response.clone()));
});
} }
return response; return response;
} catch (_e) { } catch (_e) {
// Offline: serve the cached app shell return new Response('Offline', { status: 503 });
const cached = await caches.match('/');
return (
cached ||
(await caches.match(event.request)) ||
new Response('Offline', { status: 503 })
);
} }
})() })()
); );
@@ -391,6 +577,13 @@ self.addEventListener('message', (event) => {
}); });
} }
// Allow the main thread to explicitly activate a waiting service worker.
// This is used when the user acknowledges an "Update available" prompt,
// or during fresh page loads where it's safe to swap the SW.
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
// Enable/disable mobile caching mode. // Enable/disable mobile caching mode.
// Sent from main thread after detecting the device is mobile. // Sent from main thread after detecting the device is mobile.
// This allows the SW to apply mobile-specific caching strategies. // This allows the SW to apply mobile-specific caching strategies.
@@ -404,22 +597,30 @@ self.addEventListener('message', (event) => {
// Called from the main thread after the initial render is complete, // Called from the main thread after the initial render is complete,
// so we don't compete with critical resource loading on mobile. // so we don't compete with critical resource loading on mobile.
if (event.data?.type === 'PRECACHE_ASSETS' && Array.isArray(event.data.urls)) { if (event.data?.type === 'PRECACHE_ASSETS' && Array.isArray(event.data.urls)) {
caches.open(IMMUTABLE_CACHE).then((cache) => { event.waitUntil(
event.data.urls.forEach((url) => { caches.open(IMMUTABLE_CACHE).then((cache) => {
cache.match(url).then((existing) => { return Promise.all(
if (!existing) { event.data.urls.map((url) => {
fetch(url, { priority: 'low' }) // Use ignoreVary so we find assets regardless of the request mode
.then((response) => { // they were originally cached with (cors vs no-cors).
if (response.ok) { return cache.match(url, { ignoreVary: true }).then((existing) => {
cache.put(url, response); if (!existing) {
} // Fetch with cors mode to match how <script crossorigin> and
}) // <link rel="modulepreload" crossorigin> request these assets.
.catch(() => { return fetch(url, { mode: 'cors', priority: 'low' })
// Silently ignore precache failures .then((response) => {
}); if (response.ok) {
} return cache.put(url, response);
}); }
}); })
}); .catch(() => {
// Silently ignore precache failures
});
}
});
})
);
})
);
} }
}); });

View File

@@ -5,4 +5,6 @@ export { FileBrowserDialog } from './file-browser-dialog';
export { NewProjectModal } from './new-project-modal'; export { NewProjectModal } from './new-project-modal';
export { SandboxRejectionScreen } from './sandbox-rejection-screen'; export { SandboxRejectionScreen } from './sandbox-rejection-screen';
export { SandboxRiskDialog } from './sandbox-risk-dialog'; export { SandboxRiskDialog } from './sandbox-risk-dialog';
export { PRCommentResolutionDialog } from './pr-comment-resolution-dialog';
export type { PRCommentResolutionPRInfo } from './pr-comment-resolution-dialog';
export { WorkspacePickerModal } from './workspace-picker-modal'; export { WorkspacePickerModal } from './workspace-picker-modal';

File diff suppressed because it is too large Load Diff

View File

@@ -103,7 +103,15 @@ export function ProjectSwitcher() {
}; };
const handleProjectClick = useCallback( const handleProjectClick = useCallback(
(project: Project) => { async (project: Project) => {
try {
// Ensure .automaker directory structure exists before switching
await initializeProject(project.path);
} catch (error) {
console.error('Failed to initialize project during switch:', error);
// Continue with switch even if initialization fails -
// the project may already be initialized
}
setCurrentProject(project); setCurrentProject(project);
// Navigate to board view when switching projects // Navigate to board view when switching projects
navigate({ to: '/board' }); navigate({ to: '/board' });

View File

@@ -1,3 +1,4 @@
import { useCallback } from 'react';
import { import {
Folder, Folder,
ChevronDown, ChevronDown,
@@ -15,6 +16,8 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store'; import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store';
import { initializeProject } from '@/lib/project-init';
import type { Project } from '@/lib/electron';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -87,6 +90,22 @@ export function ProjectSelectorWithOptions({
} = useAppStore(); } = useAppStore();
const shortcuts = useKeyboardShortcutsConfig(); const shortcuts = useKeyboardShortcutsConfig();
// Wrap setCurrentProject to ensure .automaker is initialized before switching
const setCurrentProjectWithInit = useCallback(
async (p: Project) => {
try {
// Ensure .automaker directory structure exists before switching
await initializeProject(p.path);
} catch (error) {
console.error('Failed to initialize project during switch:', error);
// Continue with switch even if initialization fails -
// the project may already be initialized
}
setCurrentProject(p);
},
[setCurrentProject]
);
const { const {
projectSearchQuery, projectSearchQuery,
setProjectSearchQuery, setProjectSearchQuery,
@@ -99,7 +118,7 @@ export function ProjectSelectorWithOptions({
currentProject, currentProject,
isProjectPickerOpen, isProjectPickerOpen,
setIsProjectPickerOpen, setIsProjectPickerOpen,
setCurrentProject, setCurrentProject: setCurrentProjectWithInit,
}); });
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects }); const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
@@ -107,6 +126,14 @@ export function ProjectSelectorWithOptions({
const { globalTheme, setProjectTheme, setPreviewTheme, handlePreviewEnter, handlePreviewLeave } = const { globalTheme, setProjectTheme, setPreviewTheme, handlePreviewEnter, handlePreviewLeave } =
useProjectTheme(); useProjectTheme();
const handleSelectProject = useCallback(
async (p: Project) => {
await setCurrentProjectWithInit(p);
setIsProjectPickerOpen(false);
},
[setCurrentProjectWithInit, setIsProjectPickerOpen]
);
if (!sidebarOpen || projects.length === 0) { if (!sidebarOpen || projects.length === 0) {
return null; return null;
} }
@@ -204,10 +231,7 @@ export function ProjectSelectorWithOptions({
project={project} project={project}
currentProjectId={currentProject?.id} currentProjectId={currentProject?.id}
isHighlighted={index === selectedProjectIndex} isHighlighted={index === selectedProjectIndex}
onSelect={(p) => { onSelect={handleSelectProject}
setCurrentProject(p);
setIsProjectPickerOpen(false);
}}
/> />
))} ))}
</div> </div>

View File

@@ -2,6 +2,7 @@ import { useMemo, useState, useEffect } from 'react';
import type { NavigateOptions } from '@tanstack/react-router'; import type { NavigateOptions } from '@tanstack/react-router';
import { import {
FileText, FileText,
Folder,
LayoutGrid, LayoutGrid,
Bot, Bot,
BookOpen, BookOpen,
@@ -142,7 +143,7 @@ export function useNavigation({
return true; return true;
}); });
// Build project items - Terminal is conditionally included // Build project items - Terminal and File Editor are conditionally included
const projectItems: NavItem[] = [ const projectItems: NavItem[] = [
{ {
id: 'board', id: 'board',
@@ -156,6 +157,11 @@ export function useNavigation({
icon: Network, icon: Network,
shortcut: shortcuts.graph, shortcut: shortcuts.graph,
}, },
{
id: 'file-editor',
label: 'File Editor',
icon: Folder,
},
{ {
id: 'agent', id: 'agent',
label: 'Agent Runner', label: 'Agent Runner',

View File

@@ -6,7 +6,7 @@ interface UseProjectPickerProps {
currentProject: Project | null; currentProject: Project | null;
isProjectPickerOpen: boolean; isProjectPickerOpen: boolean;
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void; setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
setCurrentProject: (project: Project) => void; setCurrentProject: (project: Project) => void | Promise<void>;
} }
export function useProjectPicker({ export function useProjectPicker({
@@ -92,9 +92,9 @@ export function useProjectPicker({
}, [selectedProjectIndex, isProjectPickerOpen, filteredProjects, scrollToProject]); }, [selectedProjectIndex, isProjectPickerOpen, filteredProjects, scrollToProject]);
// Handle selecting the currently highlighted project // Handle selecting the currently highlighted project
const selectHighlightedProject = useCallback(() => { const selectHighlightedProject = useCallback(async () => {
if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) { if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) {
setCurrentProject(filteredProjects[selectedProjectIndex]); await setCurrentProject(filteredProjects[selectedProjectIndex]);
setIsProjectPickerOpen(false); setIsProjectPickerOpen(false);
} }
}, [filteredProjects, selectedProjectIndex, setCurrentProject, setIsProjectPickerOpen]); }, [filteredProjects, selectedProjectIndex, setCurrentProject, setIsProjectPickerOpen]);

View File

@@ -25,7 +25,7 @@ export interface SortableProjectItemProps {
project: Project; project: Project;
currentProjectId: string | undefined; currentProjectId: string | undefined;
isHighlighted: boolean; isHighlighted: boolean;
onSelect: (project: Project) => void; onSelect: (project: Project) => void | Promise<void>;
} }
export interface ThemeMenuItemProps { export interface ThemeMenuItemProps {

View File

@@ -11,6 +11,8 @@ interface BranchAutocompleteProps {
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
error?: boolean; error?: boolean;
allowCreate?: boolean; // Whether to allow creating new branches (default: true)
emptyMessage?: string; // Message shown when no branches match the search
'data-testid'?: string; 'data-testid'?: string;
} }
@@ -23,6 +25,8 @@ export function BranchAutocomplete({
className, className,
disabled = false, disabled = false,
error = false, error = false,
allowCreate = true,
emptyMessage = 'No branches found.',
'data-testid': testId, 'data-testid': testId,
}: BranchAutocompleteProps) { }: BranchAutocompleteProps) {
// Always include "main" at the top of suggestions // Always include "main" at the top of suggestions
@@ -52,13 +56,13 @@ export function BranchAutocomplete({
onChange={onChange} onChange={onChange}
options={branchOptions} options={branchOptions}
placeholder={placeholder} placeholder={placeholder}
searchPlaceholder="Search or type new branch..." searchPlaceholder={allowCreate ? 'Search or type new branch...' : 'Search branches...'}
emptyMessage="No branches found." emptyMessage={emptyMessage}
className={className} className={className}
disabled={disabled} disabled={disabled}
error={error} error={error}
icon={GitBranch} icon={GitBranch}
allowCreate allowCreate={allowCreate}
createLabel={(v) => `Create "${v}"`} createLabel={(v) => `Create "${v}"`}
data-testid={testId} data-testid={testId}
itemTestIdPrefix="branch-option" itemTestIdPrefix="branch-option"

View File

@@ -83,12 +83,25 @@ export type DialogContentProps = Omit<
> & { > & {
showCloseButton?: boolean; showCloseButton?: boolean;
compact?: boolean; compact?: boolean;
/** When true, the default sm:max-w-2xl is not applied, allowing className to set max-width. */
noDefaultMaxWidth?: boolean;
}; };
const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>( const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
({ className, children, showCloseButton = true, compact = false, ...props }, ref) => { (
// Check if className contains a custom max-width {
const hasCustomMaxWidth = typeof className === 'string' && className.includes('max-w-'); className,
children,
showCloseButton = true,
compact = false,
noDefaultMaxWidth = false,
...props
},
ref
) => {
// Check if className contains a custom max-width (fallback heuristic)
const hasCustomMaxWidth =
noDefaultMaxWidth || (typeof className === 'string' && className.includes('max-w-'));
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
@@ -97,8 +110,10 @@ const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
ref={ref} ref={ref}
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
'fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]', 'fixed left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
'flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100dvh-4rem)]', 'top-[calc(50%_+_(env(safe-area-inset-top,0px)_-_env(safe-area-inset-bottom,0px))_/_2)]',
'flex flex-col w-full max-w-[calc(100%-2rem)]',
'max-h-[calc(100dvh_-_4rem_-_env(safe-area-inset-top,0px)_-_env(safe-area-inset-bottom,0px))]',
'bg-card border border-border rounded-xl shadow-2xl', 'bg-card border border-border rounded-xl shadow-2xl',
// Premium shadow // Premium shadow
'shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]', 'shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]',
@@ -108,7 +123,11 @@ const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]', 'data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]',
'duration-200', 'duration-200',
compact ? 'max-w-4xl p-4' : !hasCustomMaxWidth ? 'sm:max-w-2xl p-6' : 'p-6', compact
? 'max-w-[min(56rem,calc(100%-2rem))] p-4'
: !hasCustomMaxWidth
? 'sm:max-w-2xl p-6'
: 'p-6',
className className
)} )}
{...props} {...props}
@@ -118,13 +137,13 @@ const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
<DialogClosePrimitive <DialogClosePrimitive
data-slot="dialog-close" data-slot="dialog-close"
className={cn( className={cn(
'absolute rounded-lg opacity-60 transition-all duration-200 cursor-pointer', 'absolute z-10 rounded-lg opacity-60 transition-all duration-200 cursor-pointer',
'hover:opacity-100 hover:bg-muted', 'hover:opacity-100 hover:bg-muted',
'focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none', 'focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none',
'disabled:pointer-events-none disabled:cursor-not-allowed', 'disabled:pointer-events-none disabled:cursor-not-allowed',
'[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4', '[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4',
'p-1.5', 'p-2 min-w-[2.5rem] min-h-[2.5rem] flex items-center justify-center',
compact ? 'top-2 right-3' : 'top-4 right-4' compact ? 'top-2 right-2' : 'top-3 right-3'
)} )}
> >
<XIcon /> <XIcon />

View File

@@ -10,6 +10,7 @@ import {
ChevronRight, ChevronRight,
RefreshCw, RefreshCw,
GitBranch, GitBranch,
GitMerge,
AlertCircle, AlertCircle,
Plus, Plus,
Minus, Minus,
@@ -20,7 +21,7 @@ import { Button } from './button';
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries'; import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { FileStatus } from '@/types/electron'; import type { FileStatus, MergeStateInfo } from '@/types/electron';
interface GitDiffPanelProps { interface GitDiffPanelProps {
projectPath: string; projectPath: string;
@@ -318,6 +319,86 @@ function StagingBadge({ state }: { state: 'staged' | 'unstaged' | 'partial' }) {
); );
} }
function MergeBadge({ mergeType }: { mergeType?: string }) {
if (!mergeType) return null;
const label = (() => {
switch (mergeType) {
case 'both-modified':
return 'Both Modified';
case 'added-by-us':
return 'Added by Us';
case 'added-by-them':
return 'Added by Them';
case 'deleted-by-us':
return 'Deleted by Us';
case 'deleted-by-them':
return 'Deleted by Them';
case 'both-added':
return 'Both Added';
case 'both-deleted':
return 'Both Deleted';
case 'merged':
return 'Merged';
default:
return 'Merge';
}
})();
return (
<span className="text-[10px] px-1.5 py-0.5 rounded border font-medium bg-purple-500/15 text-purple-400 border-purple-500/30 inline-flex items-center gap-1">
<GitMerge className="w-2.5 h-2.5" />
{label}
</span>
);
}
function MergeStateBanner({ mergeState }: { mergeState: MergeStateInfo }) {
// Completed merge commit (HEAD is a merge)
if (mergeState.isMergeCommit && !mergeState.isMerging) {
return (
<div className="mx-4 mt-3 flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
<GitMerge className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<span className="font-medium text-purple-400">Merge commit</span>
<span className="text-purple-400/80 ml-1">
&mdash; {mergeState.mergeAffectedFiles.length} file
{mergeState.mergeAffectedFiles.length !== 1 ? 's' : ''} changed in merge
</span>
</div>
</div>
);
}
// In-progress merge/rebase/cherry-pick
const operationLabel =
mergeState.mergeOperationType === 'cherry-pick'
? 'Cherry-pick'
: mergeState.mergeOperationType === 'rebase'
? 'Rebase'
: 'Merge';
return (
<div className="mx-4 mt-3 flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
<GitMerge className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<span className="font-medium text-purple-400">{operationLabel} in progress</span>
{mergeState.conflictFiles.length > 0 ? (
<span className="text-purple-400/80 ml-1">
&mdash; {mergeState.conflictFiles.length} file
{mergeState.conflictFiles.length !== 1 ? 's' : ''} with conflicts
</span>
) : mergeState.isCleanMerge ? (
<span className="text-purple-400/80 ml-1">
&mdash; Clean merge, {mergeState.mergeAffectedFiles.length} file
{mergeState.mergeAffectedFiles.length !== 1 ? 's' : ''} affected
</span>
) : null}
</div>
</div>
);
}
function FileDiffSection({ function FileDiffSection({
fileDiff, fileDiff,
isExpanded, isExpanded,
@@ -348,16 +429,31 @@ function FileDiffSection({
const stagingState = fileStatus ? getStagingState(fileStatus) : undefined; const stagingState = fileStatus ? getStagingState(fileStatus) : undefined;
const isMergeFile = fileStatus?.isMergeAffected;
return ( return (
<div className="border border-border rounded-lg overflow-hidden"> <div
<div className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card hover:bg-accent/50 transition-colors"> 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"> <button onClick={onToggle} className="flex items-center gap-2 flex-1 min-w-0 text-left">
{isExpanded ? ( {isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" /> <ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
) : ( ) : (
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" /> <ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)} )}
{fileStatus ? ( {isMergeFile ? (
<GitMerge className="w-4 h-4 text-purple-500 flex-shrink-0" />
) : fileStatus ? (
getFileIcon(fileStatus.status) getFileIcon(fileStatus.status)
) : ( ) : (
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" /> <FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
@@ -367,7 +463,9 @@ function FileDiffSection({
className="flex-1 text-sm font-mono text-foreground" className="flex-1 text-sm font-mono text-foreground"
/> />
</button> </button>
<div className="flex items-center gap-2 flex-shrink-0"> {/* 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} />} {enableStaging && stagingState && <StagingBadge state={stagingState} />}
{fileDiff.isNew && ( {fileDiff.isNew && (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400"> <span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
@@ -481,9 +579,10 @@ export function GitDiffPanel({
const isLoading = useWorktrees ? isLoadingWorktree : isLoadingGit; const isLoading = useWorktrees ? isLoadingWorktree : isLoadingGit;
const queryError = useWorktrees ? worktreeError : gitError; const queryError = useWorktrees ? worktreeError : gitError;
// Extract files and diff content from the data // Extract files, diff content, and merge state from the data
const files: FileStatus[] = diffsData?.files ?? []; const files: FileStatus[] = diffsData?.files ?? [];
const diffContent = diffsData?.diff ?? ''; const diffContent = diffsData?.diff ?? '';
const mergeState: MergeStateInfo | undefined = diffsData?.mergeState;
const error = queryError const error = queryError
? queryError instanceof Error ? queryError instanceof Error
? queryError.message ? queryError.message
@@ -493,8 +592,6 @@ export function GitDiffPanel({
// Refetch function // Refetch function
const loadDiffs = useWorktrees ? refetchWorktree : refetchGit; const loadDiffs = useWorktrees ? refetchWorktree : refetchGit;
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
// Build a map from file path to FileStatus for quick lookup // Build a map from file path to FileStatus for quick lookup
const fileStatusMap = useMemo(() => { const fileStatusMap = useMemo(() => {
const map = new Map<string, FileStatus>(); const map = new Map<string, FileStatus>();
@@ -504,6 +601,24 @@ export function GitDiffPanel({
return map; return map;
}, [files]); }, [files]);
const parsedDiffs = useMemo(() => {
const diffs = parseDiff(diffContent);
// Sort: merge-affected files first, then preserve original order
if (mergeState?.isMerging || mergeState?.isMergeCommit) {
const mergeSet = new Set(mergeState.mergeAffectedFiles);
diffs.sort((a, b) => {
const aIsMerge =
mergeSet.has(a.filePath) || (fileStatusMap.get(a.filePath)?.isMergeAffected ?? false);
const bIsMerge =
mergeSet.has(b.filePath) || (fileStatusMap.get(b.filePath)?.isMergeAffected ?? false);
if (aIsMerge && !bIsMerge) return -1;
if (!aIsMerge && bIsMerge) return 1;
return 0;
});
}
return diffs;
}, [diffContent, mergeState, fileStatusMap]);
const toggleFile = (filePath: string) => { const toggleFile = (filePath: string) => {
setExpandedFiles((prev) => { setExpandedFiles((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -680,6 +795,18 @@ export function GitDiffPanel({
); );
}, [worktreePath, projectPath, useWorktrees, enableStaging, files, executeStagingAction]); }, [worktreePath, projectPath, useWorktrees, enableStaging, files, executeStagingAction]);
// Compute merge summary
const mergeSummary = useMemo(() => {
const mergeFiles = files.filter((f) => f.isMergeAffected);
if (mergeFiles.length === 0) return null;
return {
total: mergeFiles.length,
conflicted: mergeFiles.filter(
(f) => f.mergeType === 'both-modified' || f.mergeType === 'both-added'
).length,
};
}, [files]);
// Compute staging summary // Compute staging summary
const stagingSummary = useMemo(() => { const stagingSummary = useMemo(() => {
if (!enableStaging) return null; if (!enableStaging) return null;
@@ -774,9 +901,14 @@ export function GitDiffPanel({
</div> </div>
) : ( ) : (
<div> <div>
{/* Merge state banner */}
{(mergeState?.isMerging || mergeState?.isMergeCommit) && (
<MergeStateBanner mergeState={mergeState} />
)}
{/* Summary bar */} {/* Summary bar */}
<div className="p-4 pb-2 border-b border-border-glass"> <div className="p-4 pb-2 border-b border-border-glass">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4 flex-wrap"> <div className="flex items-center gap-4 flex-wrap">
{(() => { {(() => {
// Group files by status // Group files by status
@@ -797,7 +929,7 @@ export function GitDiffPanel({
{} as Record<string, { count: number; statusText: string; files: string[] }> {} as Record<string, { count: number; statusText: string; files: string[] }>
); );
return Object.entries(statusGroups).map(([status, group]) => ( const groups = Object.entries(statusGroups).map(([status, group]) => (
<div <div
key={status} key={status}
className="flex items-center gap-1.5" className="flex items-center gap-1.5"
@@ -815,9 +947,27 @@ export function GitDiffPanel({
</span> </span>
</div> </div>
)); ));
// Add merge group indicator if merge files exist
if (mergeSummary) {
groups.unshift(
<div
key="merge"
className="flex items-center gap-1.5"
data-testid="git-status-group-merge"
>
<GitMerge className="w-4 h-4 text-purple-500" />
<span className="text-xs px-1.5 py-0.5 rounded border font-medium bg-purple-500/20 text-purple-400 border-purple-500/30">
{mergeSummary.total} Merge
</span>
</div>
);
}
return groups;
})()} })()}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1 flex-wrap">
{enableStaging && stagingSummary && ( {enableStaging && stagingSummary && (
<> <>
<Button <Button
@@ -877,7 +1027,7 @@ export function GitDiffPanel({
</div> </div>
{/* Stats */} {/* Stats */}
<div className="flex items-center gap-4 text-sm mt-2"> <div className="flex items-center gap-4 text-sm mt-2 flex-wrap">
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{files.length} {files.length === 1 ? 'file' : 'files'} changed {files.length} {files.length === 1 ? 'file' : 'files'} changed
</span> </span>
@@ -905,7 +1055,7 @@ export function GitDiffPanel({
fileDiff={fileDiff} fileDiff={fileDiff}
isExpanded={expandedFiles.has(fileDiff.filePath)} isExpanded={expandedFiles.has(fileDiff.filePath)}
onToggle={() => toggleFile(fileDiff.filePath)} onToggle={() => toggleFile(fileDiff.filePath)}
fileStatus={enableStaging ? fileStatusMap.get(fileDiff.filePath) : undefined} fileStatus={fileStatusMap.get(fileDiff.filePath)}
enableStaging={enableStaging} enableStaging={enableStaging}
onStage={enableStaging ? handleStageFile : undefined} onStage={enableStaging ? handleStageFile : undefined}
onUnstage={enableStaging ? handleUnstageFile : undefined} onUnstage={enableStaging ? handleUnstageFile : undefined}
@@ -917,55 +1067,75 @@ export function GitDiffPanel({
<div className="space-y-2"> <div className="space-y-2">
{files.map((file) => { {files.map((file) => {
const stagingState = getStagingState(file); const stagingState = getStagingState(file);
const isFileMerge = file.isMergeAffected;
return ( return (
<div <div
key={file.path} key={file.path}
className="border border-border rounded-lg overflow-hidden" className={cn(
'border rounded-lg overflow-hidden',
isFileMerge ? 'border-purple-500/40' : 'border-border'
)}
> >
<div className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card"> <div
{getFileIcon(file.status)} className={cn(
<TruncatedFilePath 'w-full px-3 py-2 flex flex-col gap-1 text-left sm:flex-row sm:items-center sm:gap-2',
path={file.path} isFileMerge ? 'bg-purple-500/5 hover:bg-purple-500/10' : 'bg-card'
className="flex-1 text-sm font-mono text-foreground"
/>
{enableStaging && <StagingBadge state={stagingState} />}
<span
className={cn(
'text-xs px-1.5 py-0.5 rounded border font-medium',
getStatusBadgeColor(file.status)
)}
>
{getStatusDisplayName(file.status)}
</span>
{enableStaging && (
<div className="flex items-center gap-1 ml-1">
{stagingInProgress.has(file.path) ? (
<Spinner size="sm" />
) : stagingState === 'staged' || stagingState === 'partial' ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => handleUnstageFile(file.path)}
title="Unstage file"
>
<Minus className="w-3 h-3 mr-1" />
Unstage
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => handleStageFile(file.path)}
title="Stage file"
>
<Plus className="w-3 h-3 mr-1" />
Stage
</Button>
)}
</div>
)} )}
>
{/* File name row */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{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"
/>
</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(
'text-xs px-1.5 py-0.5 rounded border font-medium',
getStatusBadgeColor(file.status)
)}
>
{getStatusDisplayName(file.status)}
</span>
{enableStaging && (
<div className="flex items-center gap-1 ml-1">
{stagingInProgress.has(file.path) ? (
<Spinner size="sm" />
) : stagingState === 'staged' || stagingState === 'partial' ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => void handleUnstageFile(file.path)}
title="Unstage file"
>
<Minus className="w-3 h-3 mr-1" />
Unstage
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => void handleStageFile(file.path)}
title="Stage file"
>
<Plus className="w-3 h-3 mr-1" />
Stage
</Button>
)}
</div>
)}
</div>
</div> </div>
<div className="px-4 py-3 text-sm text-muted-foreground bg-background border-t border-border"> <div className="px-4 py-3 text-sm text-muted-foreground bg-background border-t border-border">
{file.status === '?' ? ( {file.status === '?' ? (

View File

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

View File

@@ -215,7 +215,7 @@ function TestLogsPanelContent({
return ( return (
<> <>
{/* Header */} {/* Header */}
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12"> <DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12 dialog-compact-header-mobile">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2 text-base"> <DialogTitle className="flex items-center gap-2 text-base">
<FlaskConical className="w-4 h-4 text-primary" /> <FlaskConical className="w-4 h-4 text-primary" />
@@ -410,7 +410,7 @@ export function TestLogsPanel({
return ( return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}> <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent <DialogContent
className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden" className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden dialog-fullscreen-mobile"
data-testid="test-logs-panel" data-testid="test-logs-panel"
compact compact
> >

View File

@@ -49,6 +49,8 @@ export function AgentView() {
// Ref for quick create session function from SessionManager // Ref for quick create session function from SessionManager
const quickCreateSessionRef = useRef<(() => Promise<void>) | null>(null); const quickCreateSessionRef = useRef<(() => Promise<void>) | null>(null);
// Guard to prevent concurrent invocations of handleCreateSessionFromEmptyState
const createSessionInFlightRef = useRef(false);
// Session management hook // Session management hook
const { currentSessionId, handleSelectSession } = useAgentSession({ const { currentSessionId, handleSelectSession } = useAgentSession({
@@ -130,6 +132,51 @@ export function AgentView() {
await clearHistory(); await clearHistory();
}; };
// Handle creating a new session from empty state.
// On mobile the SessionManager may be unmounted (hidden), clearing the ref.
// In that case, show it first and wait for the component to mount and
// re-populate quickCreateSessionRef before invoking it.
//
// A single requestAnimationFrame isn't always sufficient — React concurrent
// mode or slow devices may not have committed the SessionManager mount by
// the next frame. We use a double-RAF with a short retry loop to wait more
// robustly for the ref to be populated.
const handleCreateSessionFromEmptyState = useCallback(async () => {
if (createSessionInFlightRef.current) return;
createSessionInFlightRef.current = true;
try {
let createFn = quickCreateSessionRef.current;
if (!createFn) {
// SessionManager is likely unmounted on mobile — show it so it mounts
setShowSessionManager(true);
// Wait for mount: double RAF + retry loop (handles concurrent mode & slow devices)
const MAX_RETRIES = 5;
for (let i = 0; i < MAX_RETRIES; i++) {
await new Promise<void>((r) =>
requestAnimationFrame(() => requestAnimationFrame(() => r()))
);
createFn = quickCreateSessionRef.current;
if (createFn) break;
// Small delay between retries to give React time to commit
if (i < MAX_RETRIES - 1) {
await new Promise<void>((r) => setTimeout(r, 50));
createFn = quickCreateSessionRef.current;
if (createFn) break;
}
}
}
if (createFn) {
await createFn();
} else {
console.warn(
'[AgentView] quickCreateSessionRef was not populated after retries — SessionManager may not have mounted'
);
}
} finally {
createSessionInFlightRef.current = false;
}
}, []);
// Auto-focus input when session is selected/changed // Auto-focus input when session is selected/changed
useEffect(() => { useEffect(() => {
if (currentSessionId && inputRef.current) { if (currentSessionId && inputRef.current) {
@@ -177,7 +224,7 @@ export function AgentView() {
{/* Session Manager Sidebar */} {/* Session Manager Sidebar */}
{showSessionManager && currentProject && ( {showSessionManager && currentProject && (
<div className="fixed inset-y-0 left-0 w-72 z-30 lg:relative lg:w-80 lg:z-auto border-r border-border shrink-0 bg-card"> <div className="fixed inset-y-0 left-0 w-72 z-30 pt-[env(safe-area-inset-top,0px)] lg:pt-0 lg:relative lg:w-80 lg:z-auto border-r border-border shrink-0 bg-card">
<SessionManager <SessionManager
currentSessionId={currentSessionId} currentSessionId={currentSessionId}
onSelectSession={handleSelectSession} onSelectSession={handleSelectSession}
@@ -212,6 +259,7 @@ export function AgentView() {
messagesContainerRef={messagesContainerRef} messagesContainerRef={messagesContainerRef}
onScroll={handleScroll} onScroll={handleScroll}
onShowSessionManager={() => setShowSessionManager(true)} onShowSessionManager={() => setShowSessionManager(true)}
onCreateSession={handleCreateSessionFromEmptyState}
/> />
{/* Input Area */} {/* Input Area */}

View File

@@ -19,6 +19,7 @@ interface ChatAreaProps {
messagesContainerRef: React.RefObject<HTMLDivElement | null>; messagesContainerRef: React.RefObject<HTMLDivElement | null>;
onScroll: () => void; onScroll: () => void;
onShowSessionManager: () => void; onShowSessionManager: () => void;
onCreateSession?: () => void;
} }
export function ChatArea({ export function ChatArea({
@@ -29,12 +30,14 @@ export function ChatArea({
messagesContainerRef, messagesContainerRef,
onScroll, onScroll,
onShowSessionManager, onShowSessionManager,
onCreateSession,
}: ChatAreaProps) { }: ChatAreaProps) {
if (!currentSessionId) { if (!currentSessionId) {
return ( return (
<NoSessionState <NoSessionState
showSessionManager={showSessionManager} showSessionManager={showSessionManager}
onShowSessionManager={onShowSessionManager} onShowSessionManager={onShowSessionManager}
onCreateSession={onCreateSession}
/> />
); );
} }

View File

@@ -1,4 +1,4 @@
import { Sparkles, Bot, PanelLeft } from 'lucide-react'; import { Sparkles, Bot, PanelLeft, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
export function NoProjectState() { export function NoProjectState() {
@@ -23,9 +23,14 @@ export function NoProjectState() {
interface NoSessionStateProps { interface NoSessionStateProps {
showSessionManager: boolean; showSessionManager: boolean;
onShowSessionManager: () => void; onShowSessionManager: () => void;
onCreateSession?: () => void;
} }
export function NoSessionState({ showSessionManager, onShowSessionManager }: NoSessionStateProps) { export function NoSessionState({
showSessionManager,
onShowSessionManager,
onCreateSession,
}: NoSessionStateProps) {
return ( return (
<div <div
className="flex-1 flex items-center justify-center bg-background" className="flex-1 flex items-center justify-center bg-background"
@@ -39,10 +44,23 @@ export function NoSessionState({ showSessionManager, onShowSessionManager }: NoS
<p className="text-sm text-muted-foreground mb-6 leading-relaxed"> <p className="text-sm text-muted-foreground mb-6 leading-relaxed">
Create or select a session to start chatting with the AI agent Create or select a session to start chatting with the AI agent
</p> </p>
<Button onClick={onShowSessionManager} variant="outline" className="gap-2"> <div className="flex items-center justify-center gap-3">
<PanelLeft className="w-4 h-4" /> {onCreateSession && (
{showSessionManager ? 'View' : 'Show'} Sessions <Button
</Button> onClick={onCreateSession}
variant="default"
className="gap-2"
data-testid="empty-state-new-session-button"
>
<Plus className="w-4 h-4" />
New Session
</Button>
)}
<Button onClick={onShowSessionManager} variant="outline" className="gap-2">
<PanelLeft className="w-4 h-4" />
{showSessionManager ? 'View' : 'Show'} Sessions
</Button>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useMemo } from 'react'; import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger'; import { createLogger } from '@automaker/utils/logger';
import type { PointerEvent as ReactPointerEvent } from 'react'; import type { PointerEvent as ReactPointerEvent } from 'react';
import { import {
@@ -34,7 +34,10 @@ import type { BacklogPlanResult, FeatureStatusWithPipeline } from '@automaker/ty
import { pathsEqual } from '@/lib/utils'; import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal'; import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
import { Spinner } from '@/components/ui/spinner'; import {
PRCommentResolutionDialog,
type PRCommentResolutionPRInfo,
} from '@/components/dialogs/pr-comment-resolution-dialog';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useAutoMode } from '@/hooks/use-auto-mode'; import { useAutoMode } from '@/hooks/use-auto-mode';
import { resolveModelString } from '@automaker/model-resolver'; import { resolveModelString } from '@automaker/model-resolver';
@@ -185,6 +188,9 @@ export function BoardView() {
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false); const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false); const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
const [showMergeRebaseDialog, setShowMergeRebaseDialog] = useState(false); const [showMergeRebaseDialog, setShowMergeRebaseDialog] = useState(false);
const [showPRCommentDialog, setShowPRCommentDialog] = useState(false);
const [prCommentDialogPRInfo, setPRCommentDialogPRInfo] =
useState<PRCommentResolutionPRInfo | null>(null);
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<WorktreeInfo | null>( const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<WorktreeInfo | null>(
null null
); );
@@ -376,10 +382,20 @@ export function BoardView() {
return specificTargetCollisions; return specificTargetCollisions;
} }
// Priority 2: Columns // Priority 2: Columns (including column headers and pipeline columns)
const columnCollisions = pointerCollisions.filter((collision: Collision) => const columnCollisions = pointerCollisions.filter((collision: Collision) => {
COLUMNS.some((col) => col.id === collision.id) const colId = String(collision.id);
); // Direct column ID match (e.g. 'backlog', 'in_progress')
if (COLUMNS.some((col) => col.id === colId)) return true;
// Column header droppable (e.g. 'column-header-backlog')
if (colId.startsWith('column-header-')) {
const baseId = colId.replace('column-header-', '');
return COLUMNS.some((col) => col.id === baseId) || baseId.startsWith('pipeline_');
}
// Pipeline column IDs (e.g. 'pipeline_tests')
if (colId.startsWith('pipeline_')) return true;
return false;
});
// If we found a column collision, use that // If we found a column collision, use that
if (columnCollisions.length > 0) { if (columnCollisions.length > 0) {
@@ -420,6 +436,29 @@ export function BoardView() {
// This needs to be before useBoardActions so we can pass currentWorktreeBranch // This needs to be before useBoardActions so we can pass currentWorktreeBranch
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null; const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
const currentWorktreePath = currentWorktreeInfo?.path ?? null; const currentWorktreePath = currentWorktreeInfo?.path ?? null;
// Track the previous worktree path to detect worktree switches
const prevWorktreePathRef = useRef<string | null | undefined>(undefined);
// When the active worktree changes, invalidate feature queries to ensure
// feature cards (especially their todo lists / planSpec tasks) render fresh data.
// Without this, cards that unmount when filtered out and remount when the user
// switches back may show stale or missing todo list data until the next polling cycle.
useEffect(() => {
// Skip the initial mount (prevWorktreePathRef starts as undefined)
if (prevWorktreePathRef.current === undefined) {
prevWorktreePathRef.current = currentWorktreePath;
return;
}
// Only invalidate when the worktree actually changed
if (prevWorktreePathRef.current !== currentWorktreePath && currentProject?.path) {
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
}
prevWorktreePathRef.current = currentWorktreePath;
}, [currentWorktreePath, currentProject?.path, queryClient]);
const worktreesByProject = useAppStore((s) => s.worktreesByProject); const worktreesByProject = useAppStore((s) => s.worktreesByProject);
const worktrees = useMemo( const worktrees = useMemo(
() => () =>
@@ -880,7 +919,8 @@ export function BoardView() {
// Capture existing feature IDs before adding // Capture existing feature IDs before adding
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id)); const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
try { try {
await handleAddFeature(featureData); // Create feature directly with in_progress status to avoid brief backlog flash
await handleAddFeature({ ...featureData, initialStatus: 'in_progress' });
} catch (error) { } catch (error) {
logger.error('Failed to create feature:', error); logger.error('Failed to create feature:', error);
toast.error('Failed to create feature', { toast.error('Failed to create feature', {
@@ -894,7 +934,14 @@ export function BoardView() {
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id)); const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
if (newFeature) { if (newFeature) {
await handleStartImplementation(newFeature); try {
await handleStartImplementation(newFeature);
} catch (startError) {
logger.error('Failed to start implementation for feature:', startError);
toast.error('Failed to start feature implementation', {
description: startError instanceof Error ? startError.message : 'An error occurred',
});
}
} else { } else {
logger.error('Could not find newly created feature to start it automatically.'); logger.error('Could not find newly created feature to start it automatically.');
toast.error('Failed to auto-start feature', { toast.error('Failed to auto-start feature', {
@@ -905,26 +952,39 @@ export function BoardView() {
[handleAddFeature, handleStartImplementation] [handleAddFeature, handleStartImplementation]
); );
// Handler for addressing PR comments - creates a feature and starts it automatically // Handler for managing PR comments - opens the PR Comment Resolution dialog
const handleAddressPRComments = useCallback( const handleAddressPRComments = useCallback((worktree: WorktreeInfo, prInfo: PRInfo) => {
setPRCommentDialogPRInfo({
number: prInfo.number,
title: prInfo.title,
// Pass the worktree's branch so features are created on the correct worktree
headRefName: worktree.branch,
});
setShowPRCommentDialog(true);
}, []);
// Handler for auto-addressing PR comments - immediately creates and starts a feature task
const handleAutoAddressPRComments = useCallback(
async (worktree: WorktreeInfo, prInfo: PRInfo) => { async (worktree: WorktreeInfo, prInfo: PRInfo) => {
// Use a simple prompt that instructs the agent to read and address PR feedback if (!prInfo.number) {
// The agent will fetch the PR comments directly, which is more reliable and up-to-date toast.error('Cannot address PR comments', {
const prNumber = prInfo.number; description: 'No PR number available for this worktree.',
const description = `Read the review requests on PR #${prNumber} and address any feedback the best you can.`; });
return;
}
const featureData = { const featureData = {
title: `Address PR #${prNumber} Review Comments`, title: `Address PR #${prInfo.number} Review Comments`,
category: 'PR Review', category: 'Maintenance',
description, description: `Read the review requests on PR #${prInfo.number} and address any feedback the best you can.`,
images: [], images: [],
imagePaths: [], imagePaths: [],
skipTests: defaultSkipTests, skipTests: defaultSkipTests,
model: 'opus' as const, model: resolveModelString('opus'),
thinkingLevel: 'none' as const, thinkingLevel: 'none' as const,
branchName: worktree.branch, branchName: worktree.branch,
workMode: 'custom' as const, // Use the worktree's branch workMode: 'custom' as const,
priority: 1, // High priority for PR feedback priority: 1,
planningMode: 'skip' as const, planningMode: 'skip' as const,
requirePlanApproval: false, requirePlanApproval: false,
}; };
@@ -1225,6 +1285,7 @@ export function BoardView() {
const { getColumnFeatures, completedFeatures } = useBoardColumnFeatures({ const { getColumnFeatures, completedFeatures } = useBoardColumnFeatures({
features: hookFeatures, features: hookFeatures,
runningAutoTasks, runningAutoTasks,
runningAutoTasksAllWorktrees,
searchQuery, searchQuery,
currentWorktreePath, currentWorktreePath,
currentWorktreeBranch, currentWorktreeBranch,
@@ -1393,14 +1454,6 @@ export function BoardView() {
); );
} }
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="board-view-loading">
<Spinner size="lg" />
</div>
);
}
return ( return (
<div <div
className="flex-1 flex flex-col overflow-hidden content-bg relative" className="flex-1 flex flex-col overflow-hidden content-bg relative"
@@ -1426,13 +1479,12 @@ export function BoardView() {
}, },
}); });
// Also update backend if auto mode is running // Also update backend if auto mode is running.
// Use restartWithConcurrency to avoid toggle flickering - it restarts
// the backend without toggling isRunning off/on in the UI.
if (autoMode.isRunning) { if (autoMode.isRunning) {
// Restart auto mode with new concurrency (backend will handle this) autoMode.restartWithConcurrency().catch((error) => {
autoMode.stop().then(() => { logger.error('[AutoMode] Failed to restart with new concurrency:', error);
autoMode.start().catch((error) => {
logger.error('[AutoMode] Failed to restart with new concurrency:', error);
});
}); });
} }
} }
@@ -1499,6 +1551,7 @@ export function BoardView() {
setShowCreateBranchDialog(true); setShowCreateBranchDialog(true);
}} }}
onAddressPRComments={handleAddressPRComments} onAddressPRComments={handleAddressPRComments}
onAutoAddressPRComments={handleAutoAddressPRComments}
onResolveConflicts={handleResolveConflicts} onResolveConflicts={handleResolveConflicts}
onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature} onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
onBranchSwitchConflict={handleBranchSwitchConflict} onBranchSwitchConflict={handleBranchSwitchConflict}
@@ -1976,6 +2029,18 @@ export function BoardView() {
}} }}
/> />
{/* PR Comment Resolution Dialog */}
{prCommentDialogPRInfo && (
<PRCommentResolutionDialog
open={showPRCommentDialog}
onOpenChange={(open) => {
setShowPRCommentDialog(open);
if (!open) setPRCommentDialogPRInfo(null);
}}
pr={prCommentDialogPRInfo}
/>
)}
{/* Init Script Indicator - floating overlay for worktree init script status */} {/* Init Script Indicator - floating overlay for worktree init script status */}
{getShowInitScriptIndicator(currentProject.path) && ( {getShowInitScriptIndicator(currentProject.path) && (
<InitScriptIndicator projectPath={currentProject.path} /> <InitScriptIndicator projectPath={currentProject.path} />

View File

@@ -1,4 +1,5 @@
import { memo, useEffect, useState, useMemo, useRef } from 'react'; import { memo, useEffect, useState, useMemo, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Feature, ThinkingLevel, ReasoningEffort, ParsedTask } from '@/store/app-store'; import { Feature, ThinkingLevel, ReasoningEffort, ParsedTask } from '@/store/app-store';
import { getProviderFromModel } from '@/lib/utils'; import { getProviderFromModel } from '@/lib/utils';
import { parseAgentContext, formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser'; import { parseAgentContext, formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
@@ -10,6 +11,7 @@ import { getElectronAPI } from '@/lib/electron';
import { SummaryDialog } from './summary-dialog'; import { SummaryDialog } from './summary-dialog';
import { getProviderIconForModel } from '@/components/ui/provider-icon'; import { getProviderIconForModel } from '@/components/ui/provider-icon';
import { useFeature, useAgentOutput } from '@/hooks/queries'; import { useFeature, useAgentOutput } from '@/hooks/queries';
import { queryKeys } from '@/lib/query-keys';
/** /**
* Formats thinking level for compact display * Formats thinking level for compact display
@@ -58,6 +60,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
summary, summary,
isActivelyRunning, isActivelyRunning,
}: AgentInfoPanelProps) { }: AgentInfoPanelProps) {
const queryClient = useQueryClient();
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false); const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isTodosExpanded, setIsTodosExpanded] = useState(false); const [isTodosExpanded, setIsTodosExpanded] = useState(false);
// Track real-time task status updates from WebSocket events // Track real-time task status updates from WebSocket events
@@ -130,6 +133,25 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
pollingInterval, pollingInterval,
}); });
// On mount, ensure feature and agent output queries are fresh.
// This handles the worktree switch scenario where cards unmount when filtered out
// and remount when the user switches back. Without this, the React Query cache
// may serve stale data (or no data) for the individual feature query, causing
// the todo list to appear empty until the next polling cycle.
useEffect(() => {
if (shouldFetchData && projectPath && feature.id && !contextContent) {
// Invalidate both the single feature and agent output queries to trigger immediate refetch
queryClient.invalidateQueries({
queryKey: queryKeys.features.single(projectPath, feature.id),
});
queryClient.invalidateQueries({
queryKey: queryKeys.features.agentOutput(projectPath, feature.id),
});
}
// Only run on mount (feature.id and projectPath identify this specific card instance)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [feature.id, projectPath]);
// Parse agent output into agentInfo // Parse agent output into agentInfo
const agentInfo = useMemo(() => { const agentInfo = useMemo(() => {
if (contextContent) { if (contextContent) {
@@ -305,9 +327,11 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
// Agent Info Panel for non-backlog cards // Agent Info Panel for non-backlog cards
// Show panel if we have agentInfo OR planSpec.tasks (for spec/full mode) // Show panel if we have agentInfo OR planSpec.tasks (for spec/full mode)
// OR if the feature has effective todos from any source (handles initial mount after worktree switch)
// OR if the feature is actively running (ensures panel stays visible during execution)
// Note: hasPlanSpecTasks is already defined above and includes freshPlanSpec // Note: hasPlanSpecTasks is already defined above and includes freshPlanSpec
// (The backlog case was already handled above and returned early) // (The backlog case was already handled above and returned early)
if (agentInfo || hasPlanSpecTasks) { if (agentInfo || hasPlanSpecTasks || effectiveTodos.length > 0 || isActivelyRunning) {
return ( return (
<> <>
<div className="mb-3 space-y-2 overflow-hidden"> <div className="mb-3 space-y-2 overflow-hidden">

View File

@@ -17,6 +17,8 @@ import {
interface CardActionsProps { interface CardActionsProps {
feature: Feature; feature: Feature;
isCurrentAutoTask: boolean; isCurrentAutoTask: boolean;
/** Whether this feature is tracked as a running task (may be true even before status updates to in_progress) */
isRunningTask?: boolean;
hasContext?: boolean; hasContext?: boolean;
shortcutKey?: string; shortcutKey?: string;
isSelectionMode?: boolean; isSelectionMode?: boolean;
@@ -36,6 +38,7 @@ interface CardActionsProps {
export const CardActions = memo(function CardActions({ export const CardActions = memo(function CardActions({
feature, feature,
isCurrentAutoTask, isCurrentAutoTask,
isRunningTask = false,
hasContext: _hasContext, hasContext: _hasContext,
shortcutKey, shortcutKey,
isSelectionMode = false, isSelectionMode = false,
@@ -340,7 +343,57 @@ export const CardActions = memo(function CardActions({
) : null} ) : null}
</> </>
)} )}
{/* Running task with stale status: feature is tracked as running but status hasn't updated yet.
Show Logs/Stop controls instead of Make to avoid confusing UI. */}
{!isCurrentAutoTask && {!isCurrentAutoTask &&
isRunningTask &&
(feature.status === 'backlog' ||
feature.status === 'interrupted' ||
feature.status === 'ready') && (
<>
{onViewOutput && (
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-[11px]"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Logs</span>
{shortcutKey && (
<span
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-foreground/10"
data-testid={`shortcut-key-${feature.id}`}
>
{shortcutKey}
</span>
)}
</Button>
)}
{onForceStop && (
<Button
variant="destructive"
size="sm"
className="h-7 text-[11px] px-2 shrink-0"
onClick={(e) => {
e.stopPropagation();
onForceStop();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`force-stop-${feature.id}`}
>
<StopCircle className="w-3 h-3" />
</Button>
)}
</>
)}
{!isCurrentAutoTask &&
!isRunningTask &&
(feature.status === 'backlog' || (feature.status === 'backlog' ||
feature.status === 'interrupted' || feature.status === 'interrupted' ||
feature.status === 'ready') && ( feature.status === 'ready') && (

View File

@@ -468,7 +468,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
</div> </div>
)} )}
<div className="flex-1 min-w-0 overflow-hidden"> <div className="flex-1 min-w-0 overflow-hidden">
{feature.titleGenerating ? ( {feature.titleGenerating && !feature.title ? (
<div className="flex items-center gap-1.5 mb-1"> <div className="flex items-center gap-1.5 mb-1">
<Spinner size="xs" /> <Spinner size="xs" />
<span className="text-xs text-muted-foreground italic">Generating title...</span> <span className="text-xs text-muted-foreground italic">Generating title...</span>

View File

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

View File

@@ -42,7 +42,12 @@ export const KanbanColumn = memo(function KanbanColumn({
contentStyle, contentStyle,
disableItemSpacing = false, disableItemSpacing = false,
}: KanbanColumnProps) { }: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id }); const { setNodeRef, isOver: isColumnOver } = useDroppable({ id });
// Also make the header explicitly a drop target so dragging to the top of the column works
const { setNodeRef: setHeaderDropRef, isOver: isHeaderOver } = useDroppable({
id: `column-header-${id}`,
});
const isOver = isColumnOver || isHeaderOver;
// Use inline style for width if provided, otherwise use default w-72 // Use inline style for width if provided, otherwise use default w-72
const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined; const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined;
@@ -70,8 +75,9 @@ export const KanbanColumn = memo(function KanbanColumn({
style={{ opacity: opacity / 100 }} style={{ opacity: opacity / 100 }}
/> />
{/* Column Header */} {/* Column Header - also registered as a drop target so dragging to the header area works */}
<div <div
ref={setHeaderDropRef}
className={cn( className={cn(
'relative z-10 flex items-center gap-3 px-3 py-2.5', 'relative z-10 flex items-center gap-3 px-3 py-2.5',
showBorder && 'border-b border-border/40' showBorder && 'border-b border-border/40'
@@ -96,8 +102,8 @@ export const KanbanColumn = memo(function KanbanColumn({
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]', '[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
// Smooth scrolling // Smooth scrolling
'scroll-smooth', 'scroll-smooth',
// Add padding at bottom if there's a footer action // Add padding at bottom if there's a footer action (less on mobile to reduce blank space)
footerAction && 'pb-14', footerAction && 'pb-12 sm:pb-14',
contentClassName contentClassName
)} )}
ref={contentRef} ref={contentRef}
@@ -109,7 +115,7 @@ export const KanbanColumn = memo(function KanbanColumn({
{/* Floating Footer Action */} {/* Floating Footer Action */}
{footerAction && ( {footerAction && (
<div className="absolute bottom-0 left-0 right-0 z-20 p-2 bg-gradient-to-t from-card/95 via-card/80 to-transparent pt-6"> <div className="absolute bottom-0 left-0 right-0 z-20 p-2 bg-gradient-to-t from-card/95 via-card/80 to-transparent pt-4 sm:pt-6">
{footerAction} {footerAction}
</div> </div>
)} )}

View File

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

View File

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

View File

@@ -123,6 +123,18 @@ interface AddFeatureDialogProps {
* This is used when the "Default to worktree mode" setting is disabled. * This is used when the "Default to worktree mode" setting is disabled.
*/ */
forceCurrentBranchMode?: boolean; forceCurrentBranchMode?: boolean;
/**
* Pre-filled title for the feature (e.g., from a GitHub issue).
*/
prefilledTitle?: string;
/**
* Pre-filled description for the feature (e.g., from a GitHub issue).
*/
prefilledDescription?: string;
/**
* Pre-filled category for the feature (e.g., 'From GitHub').
*/
prefilledCategory?: string;
} }
/** /**
@@ -149,6 +161,9 @@ export function AddFeatureDialog({
projectPath, projectPath,
selectedNonMainWorktreeBranch, selectedNonMainWorktreeBranch,
forceCurrentBranchMode, forceCurrentBranchMode,
prefilledTitle,
prefilledDescription,
prefilledCategory,
}: AddFeatureDialogProps) { }: AddFeatureDialogProps) {
const isSpawnMode = !!parentFeature; const isSpawnMode = !!parentFeature;
const navigate = useNavigate(); const navigate = useNavigate();
@@ -211,6 +226,11 @@ export function AddFeatureDialog({
wasOpenRef.current = open; wasOpenRef.current = open;
if (justOpened) { if (justOpened) {
// Initialize with prefilled values if provided, otherwise use defaults
setTitle(prefilledTitle || '');
setDescription(prefilledDescription || '');
setCategory(prefilledCategory || '');
setSkipTests(defaultSkipTests); setSkipTests(defaultSkipTests);
// When a non-main worktree is selected, use its branch name for custom mode // When a non-main worktree is selected, use its branch name for custom mode
// Otherwise, use the default branch // Otherwise, use the default branch
@@ -254,6 +274,9 @@ export function AddFeatureDialog({
forceCurrentBranchMode, forceCurrentBranchMode,
parentFeature, parentFeature,
allFeatures, allFeatures,
prefilledTitle,
prefilledDescription,
prefilledCategory,
]); ]);
// Clear requirePlanApproval when planning mode is skip or lite // Clear requirePlanApproval when planning mode is skip or lite

View File

@@ -321,11 +321,11 @@ export function AgentOutputModal({
return ( return (
<Dialog open={open} onOpenChange={onClose}> <Dialog open={open} onOpenChange={onClose}>
<DialogContent <DialogContent
className="w-full h-full max-w-full max-h-full sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col" className="w-full max-h-[85dvh] max-w-[calc(100%-2rem)] sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] rounded-xl flex flex-col"
data-testid="agent-output-modal" data-testid="agent-output-modal"
> >
<DialogHeader className="shrink-0"> <DialogHeader className="shrink-0">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-8"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-10">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && ( {featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
<Spinner size="md" /> <Spinner size="md" />

View File

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

View File

@@ -11,8 +11,16 @@ import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { import {
GitCommit, GitCommit,
GitMerge,
Sparkles, Sparkles,
FilePlus, FilePlus,
FileX, FileX,
@@ -21,6 +29,7 @@ import {
File, File,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Upload,
} from 'lucide-react'; } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
@@ -28,9 +37,14 @@ import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { TruncatedFilePath } from '@/components/ui/truncated-file-path'; import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
import type { FileStatus } from '@/types/electron'; import type { FileStatus, MergeStateInfo } from '@/types/electron';
import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils'; import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
interface RemoteInfo {
name: string;
url: string;
}
interface WorktreeInfo { interface WorktreeInfo {
path: string; path: string;
branch: string; branch: string;
@@ -103,6 +117,27 @@ const getStatusBadgeColor = (status: string) => {
} }
}; };
const getMergeTypeLabel = (mergeType?: string) => {
switch (mergeType) {
case 'both-modified':
return 'Both Modified';
case 'added-by-us':
return 'Added by Us';
case 'added-by-them':
return 'Added by Them';
case 'deleted-by-us':
return 'Deleted by Us';
case 'deleted-by-them':
return 'Deleted by Them';
case 'both-added':
return 'Both Added';
case 'both-deleted':
return 'Both Deleted';
default:
return 'Merge';
}
};
function DiffLine({ function DiffLine({
type, type,
content, content,
@@ -177,6 +212,18 @@ export function CommitWorktreeDialog({
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set()); const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [expandedFile, setExpandedFile] = useState<string | null>(null); const [expandedFile, setExpandedFile] = useState<string | null>(null);
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false); const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
const [mergeState, setMergeState] = useState<MergeStateInfo | undefined>(undefined);
// Push after commit state
const [pushAfterCommit, setPushAfterCommit] = useState(false);
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>('');
const [isLoadingRemotes, setIsLoadingRemotes] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [remotesFetched, setRemotesFetched] = useState(false);
const [remotesFetchError, setRemotesFetchError] = useState<string | null>(null);
// Track whether the commit already succeeded so retries can skip straight to push
const [commitSucceeded, setCommitSucceeded] = useState(false);
// Parse diffs // Parse diffs
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]); const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
@@ -190,6 +237,58 @@ export function CommitWorktreeDialog({
return map; return map;
}, [parsedDiffs]); }, [parsedDiffs]);
// Fetch remotes when push option is enabled
const fetchRemotesForWorktree = useCallback(
async (worktreePath: string, signal?: { cancelled: boolean }) => {
setIsLoadingRemotes(true);
setRemotesFetchError(null);
try {
const api = getElectronAPI();
if (api?.worktree?.listRemotes) {
const result = await api.worktree.listRemotes(worktreePath);
if (signal?.cancelled) return;
setRemotesFetched(true);
if (result.success && result.result) {
const remoteInfos = result.result.remotes.map((r) => ({
name: r.name,
url: r.url,
}));
setRemotes(remoteInfos);
// Auto-select 'origin' if available, otherwise first remote
if (remoteInfos.length > 0) {
const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0];
setSelectedRemote(defaultRemote.name);
}
}
} else {
// API not available — mark fetch as complete with an error so the UI
// shows feedback instead of remaining in an empty/loading state.
setRemotesFetchError('Remote listing not available');
setRemotesFetched(true);
return;
}
} catch (err) {
if (signal?.cancelled) return;
// Don't mark as successfully fetched — show an error with retry instead
setRemotesFetchError(err instanceof Error ? err.message : 'Failed to fetch remotes');
console.warn('Failed to fetch remotes:', err);
} finally {
if (!signal?.cancelled) setIsLoadingRemotes(false);
}
},
[]
);
useEffect(() => {
if (pushAfterCommit && worktree && !remotesFetched && !remotesFetchError) {
const signal = { cancelled: false };
fetchRemotesForWorktree(worktree.path, signal);
return () => {
signal.cancelled = true;
};
}
}, [pushAfterCommit, worktree, remotesFetched, remotesFetchError, fetchRemotesForWorktree]);
// Load diffs when dialog opens // Load diffs when dialog opens
useEffect(() => { useEffect(() => {
if (open && worktree) { if (open && worktree) {
@@ -198,6 +297,15 @@ export function CommitWorktreeDialog({
setDiffContent(''); setDiffContent('');
setSelectedFiles(new Set()); setSelectedFiles(new Set());
setExpandedFile(null); setExpandedFile(null);
setMergeState(undefined);
// Reset push state
setPushAfterCommit(false);
setRemotes([]);
setSelectedRemote('');
setIsPushing(false);
setRemotesFetched(false);
setRemotesFetchError(null);
setCommitSucceeded(false);
let cancelled = false; let cancelled = false;
@@ -208,8 +316,20 @@ export function CommitWorktreeDialog({
const result = await api.git.getDiffs(worktree.path); const result = await api.git.getDiffs(worktree.path);
if (result.success) { if (result.success) {
const fileList = result.files ?? []; const fileList = result.files ?? [];
// Sort merge-affected files first when a merge is in progress
if (result.mergeState?.isMerging) {
const mergeSet = new Set(result.mergeState.mergeAffectedFiles);
fileList.sort((a, b) => {
const aIsMerge = mergeSet.has(a.path) || (a.isMergeAffected ?? false);
const bIsMerge = mergeSet.has(b.path) || (b.isMergeAffected ?? false);
if (aIsMerge && !bIsMerge) return -1;
if (!aIsMerge && bIsMerge) return 1;
return 0;
});
}
if (!cancelled) setFiles(fileList); if (!cancelled) setFiles(fileList);
if (!cancelled) setDiffContent(result.diff ?? ''); if (!cancelled) setDiffContent(result.diff ?? '');
if (!cancelled) setMergeState(result.mergeState);
// If any files are already staged, pre-select only staged files // If any files are already staged, pre-select only staged files
// Otherwise select all files by default // Otherwise select all files by default
const stagedFiles = fileList.filter((f) => { const stagedFiles = fileList.filter((f) => {
@@ -278,14 +398,64 @@ export function CommitWorktreeDialog({
setExpandedFile((prev) => (prev === filePath ? null : filePath)); setExpandedFile((prev) => (prev === filePath ? null : filePath));
}, []); }, []);
/** Shared push helper — returns true if the push succeeded */
const performPush = async (
api: ReturnType<typeof getElectronAPI>,
worktreePath: string,
remoteName: string
): Promise<boolean> => {
if (!api?.worktree?.push) {
toast.error('Push API not available');
return false;
}
setIsPushing(true);
try {
const pushResult = await api.worktree.push(worktreePath, false, remoteName);
if (pushResult.success && pushResult.result) {
toast.success('Pushed to remote', {
description: pushResult.result.message,
});
return true;
} else {
toast.error(pushResult.error || 'Failed to push to remote');
return false;
}
} catch (pushErr) {
toast.error(pushErr instanceof Error ? pushErr.message : 'Failed to push to remote');
return false;
} finally {
setIsPushing(false);
}
};
const handleCommit = async () => { const handleCommit = async () => {
if (!worktree || !message.trim() || selectedFiles.size === 0) return; if (!worktree) return;
const api = getElectronAPI();
// If commit already succeeded on a previous attempt, skip straight to push (or close if no push needed)
if (commitSucceeded) {
if (pushAfterCommit && selectedRemote) {
const ok = await performPush(api, worktree.path, selectedRemote);
if (ok) {
onCommitted();
onOpenChange(false);
setMessage('');
}
} else {
onCommitted();
onOpenChange(false);
setMessage('');
}
return;
}
if (!message.trim() || selectedFiles.size === 0) return;
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const api = getElectronAPI();
if (!api?.worktree?.commit) { if (!api?.worktree?.commit) {
setError('Worktree API not available'); setError('Worktree API not available');
return; return;
@@ -299,12 +469,27 @@ export function CommitWorktreeDialog({
if (result.success && result.result) { if (result.success && result.result) {
if (result.result.committed) { if (result.result.committed) {
setCommitSucceeded(true);
toast.success('Changes committed', { toast.success('Changes committed', {
description: `Commit ${result.result.commitHash} on ${result.result.branch}`, description: `Commit ${result.result.commitHash} on ${result.result.branch}`,
}); });
onCommitted();
onOpenChange(false); // Push after commit if enabled
setMessage(''); let pushSucceeded = false;
if (pushAfterCommit && selectedRemote) {
pushSucceeded = await performPush(api, worktree.path, selectedRemote);
}
// Only close the dialog when no push was requested or the push completed successfully.
// If push failed, keep the dialog open so the user can retry.
if (!pushAfterCommit || pushSucceeded) {
onCommitted();
onOpenChange(false);
setMessage('');
} else {
// Commit succeeded but push failed — notify parent of commit but keep dialog open for retry
onCommitted();
}
} else { } else {
toast.info('No changes to commit', { toast.info('No changes to commit', {
description: result.result.message, description: result.result.message,
@@ -320,16 +505,30 @@ export function CommitWorktreeDialog({
} }
}; };
// When the commit succeeded but push failed, allow retrying the push without
// requiring a commit message or file selection.
const isPushRetry = commitSucceeded && pushAfterCommit && !isPushing;
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if ( if (
e.key === 'Enter' && e.key === 'Enter' &&
(e.metaKey || e.ctrlKey) && (e.metaKey || e.ctrlKey) &&
!isLoading && !isLoading &&
!isGenerating && !isPushing &&
message.trim() && !isGenerating
selectedFiles.size > 0
) { ) {
handleCommit(); if (isPushRetry) {
// Push retry only needs a selected remote
if (selectedRemote) {
handleCommit();
}
} else if (
message.trim() &&
selectedFiles.size > 0 &&
!(pushAfterCommit && !selectedRemote)
) {
handleCommit();
}
} }
}; };
@@ -390,8 +589,19 @@ export function CommitWorktreeDialog({
const allSelected = selectedFiles.size === files.length && files.length > 0; const allSelected = selectedFiles.size === files.length && files.length > 0;
// Prevent the dialog from being dismissed while a push is in progress.
// Overlay clicks and Escape key both route through onOpenChange(false); we
// intercept those here so the UI stays open until the push completes.
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen && isPushing) {
// Ignore close requests during an active push.
return;
}
onOpenChange(nextOpen);
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[85vh] flex flex-col"> <DialogContent className="sm:max-w-[700px] max-h-[85vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
@@ -405,6 +615,34 @@ export function CommitWorktreeDialog({
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-3 py-2 min-h-0 flex-1 overflow-hidden"> <div className="flex flex-col gap-3 py-2 min-h-0 flex-1 overflow-hidden">
{/* Merge state banner */}
{mergeState?.isMerging && (
<div className="flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
<GitMerge className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<span className="font-medium text-purple-400">
{mergeState.mergeOperationType === 'cherry-pick'
? 'Cherry-pick'
: mergeState.mergeOperationType === 'rebase'
? 'Rebase'
: 'Merge'}{' '}
in progress
</span>
{mergeState.conflictFiles.length > 0 ? (
<span className="text-purple-400/80 ml-1">
&mdash; {mergeState.conflictFiles.length} file
{mergeState.conflictFiles.length !== 1 ? 's' : ''} with conflicts
</span>
) : mergeState.isCleanMerge ? (
<span className="text-purple-400/80 ml-1">
&mdash; Clean merge, {mergeState.mergeAffectedFiles.length} file
{mergeState.mergeAffectedFiles.length !== 1 ? 's' : ''} affected
</span>
) : null}
</div>
</div>
)}
{/* File Selection */} {/* File Selection */}
<div className="flex flex-col min-h-0"> <div className="flex flex-col min-h-0">
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
@@ -451,13 +689,25 @@ export function CommitWorktreeDialog({
const isStaged = idx !== ' ' && idx !== '?'; const isStaged = idx !== ' ' && idx !== '?';
const isUnstaged = wt !== ' ' && wt !== '?'; const isUnstaged = wt !== ' ' && wt !== '?';
const isUntracked = idx === '?' && wt === '?'; const isUntracked = idx === '?' && wt === '?';
const isMergeFile =
file.isMergeAffected ||
(mergeState?.mergeAffectedFiles?.includes(file.path) ?? false);
return ( return (
<div key={file.path} className="border-b border-border last:border-b-0"> <div
key={file.path}
className={cn(
'border-b last:border-b-0',
isMergeFile ? 'border-purple-500/30' : 'border-border'
)}
>
<div <div
className={cn( className={cn(
'flex items-center gap-2 px-3 py-1.5 hover:bg-accent/50 transition-colors group', 'flex items-center gap-2 px-3 py-1.5 transition-colors group',
isExpanded && 'bg-accent/30' isMergeFile
? 'bg-purple-500/5 hover:bg-purple-500/10'
: 'hover:bg-accent/50',
isExpanded && (isMergeFile ? 'bg-purple-500/10' : 'bg-accent/30')
)} )}
> >
{/* Checkbox */} {/* Checkbox */}
@@ -477,11 +727,21 @@ export function CommitWorktreeDialog({
) : ( ) : (
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" /> <ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)} )}
{getFileIcon(file.status)} {isMergeFile ? (
<GitMerge className="w-3.5 h-3.5 text-purple-500 flex-shrink-0" />
) : (
getFileIcon(file.status)
)}
<TruncatedFilePath <TruncatedFilePath
path={file.path} path={file.path}
className="text-xs font-mono flex-1 text-foreground" className="text-xs font-mono flex-1 text-foreground"
/> />
{isMergeFile && (
<span className="text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0 bg-purple-500/15 text-purple-400 border-purple-500/30 inline-flex items-center gap-0.5">
<GitMerge className="w-2.5 h-2.5" />
{getMergeTypeLabel(file.mergeType)}
</span>
)}
<span <span
className={cn( className={cn(
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0', 'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
@@ -580,9 +840,85 @@ export function CommitWorktreeDialog({
{error && <p className="text-sm text-destructive">{error}</p>} {error && <p className="text-sm text-destructive">{error}</p>}
</div> </div>
{/* Push after commit option */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Checkbox
id="push-after-commit"
checked={pushAfterCommit}
onCheckedChange={(checked) => setPushAfterCommit(checked === true)}
/>
<Label
htmlFor="push-after-commit"
className="text-sm font-medium cursor-pointer flex items-center gap-1.5"
>
<Upload className="w-3.5 h-3.5" />
Push to remote after commit
</Label>
</div>
{pushAfterCommit && (
<div className="ml-6 flex flex-col gap-1.5">
{isLoadingRemotes || (!remotesFetched && !remotesFetchError) ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner size="sm" />
<span>Loading remotes...</span>
</div>
) : remotesFetchError ? (
<div className="flex items-center gap-2 text-sm text-destructive">
<span>Failed to load remotes.</span>
<button
className="text-xs underline hover:text-foreground transition-colors"
onClick={() => {
if (worktree) {
setRemotesFetchError(null);
}
}}
>
Retry
</button>
</div>
) : remotes.length === 0 && remotesFetched ? (
<p className="text-sm text-muted-foreground">
No remotes configured for this repository.
</p>
) : remotes.length > 0 ? (
<div className="flex items-center gap-2">
<Label
htmlFor="remote-select"
className="text-xs text-muted-foreground whitespace-nowrap"
>
Remote:
</Label>
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
<SelectTrigger id="remote-select" className="h-8 text-xs flex-1">
<SelectValue placeholder="Select remote" />
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<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>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
</div>
)}
</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Press <kbd className="px-1 py-0.5 bg-muted rounded text-xs">Cmd/Ctrl+Enter</kbd> to Press <kbd className="px-1 py-0.5 bg-muted rounded text-xs">Cmd/Ctrl+Enter</kbd> to
commit commit{pushAfterCommit ? ' & push' : ''}
</p> </p>
</div> </div>
@@ -590,23 +926,41 @@ export function CommitWorktreeDialog({
<Button <Button
variant="ghost" variant="ghost"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={isLoading || isGenerating} disabled={isLoading || isPushing || isGenerating}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleCommit} onClick={handleCommit}
disabled={isLoading || isGenerating || !message.trim() || selectedFiles.size === 0} disabled={
isLoading ||
isPushing ||
isGenerating ||
(isPushRetry
? !selectedRemote
: !message.trim() ||
selectedFiles.size === 0 ||
(pushAfterCommit && !selectedRemote))
}
> >
{isLoading ? ( {isLoading || isPushing ? (
<> <>
<Spinner size="sm" className="mr-2" /> <Spinner size="sm" className="mr-2" />
Committing... {isPushing ? 'Pushing...' : 'Committing...'}
</>
) : isPushRetry ? (
<>
<Upload className="w-4 h-4 mr-2" />
Retry Push
</> </>
) : ( ) : (
<> <>
<GitCommit className="w-4 h-4 mr-2" /> {pushAfterCommit ? (
Commit <Upload className="w-4 h-4 mr-2" />
) : (
<GitCommit className="w-4 h-4 mr-2" />
)}
{pushAfterCommit ? 'Commit & Push' : 'Commit'}
{selectedFiles.size > 0 && selectedFiles.size < files.length {selectedFiles.size > 0 && selectedFiles.size < files.length
? ` (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})` ? ` (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})`
: ''} : ''}

View File

@@ -30,6 +30,7 @@ import { useWorktreeBranches } from '@/hooks/queries';
interface RemoteInfo { interface RemoteInfo {
name: string; name: string;
url: string; url: string;
branches?: string[];
} }
interface WorktreeInfo { interface WorktreeInfo {
@@ -74,13 +75,19 @@ export function CreatePRDialog({
// Remote selection state // Remote selection state
const [remotes, setRemotes] = useState<RemoteInfo[]>([]); const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>(''); const [selectedRemote, setSelectedRemote] = useState<string>('');
// Target remote: which remote to create the PR against (may differ from push remote)
const [selectedTargetRemote, setSelectedTargetRemote] = useState<string>('');
const [isLoadingRemotes, setIsLoadingRemotes] = useState(false); const [isLoadingRemotes, setIsLoadingRemotes] = useState(false);
// Keep a ref in sync with selectedRemote so fetchRemotes can read the latest value // Keep a ref in sync with selectedRemote so fetchRemotes can read the latest value
// without needing it in its dependency array (which would cause re-fetch loops) // without needing it in its dependency array (which would cause re-fetch loops)
const selectedRemoteRef = useRef<string>(selectedRemote); const selectedRemoteRef = useRef<string>(selectedRemote);
const selectedTargetRemoteRef = useRef<string>(selectedTargetRemote);
useEffect(() => { useEffect(() => {
selectedRemoteRef.current = selectedRemote; selectedRemoteRef.current = selectedRemote;
}, [selectedRemote]); }, [selectedRemote]);
useEffect(() => {
selectedTargetRemoteRef.current = selectedTargetRemote;
}, [selectedTargetRemote]);
// Generate description state // Generate description state
const [isGeneratingDescription, setIsGeneratingDescription] = useState(false); const [isGeneratingDescription, setIsGeneratingDescription] = useState(false);
@@ -91,11 +98,115 @@ export function CreatePRDialog({
true // Include remote branches for PR base branch selection true // Include remote branches for PR base branch selection
); );
// Filter out current worktree branch from the list // Determine if push remote selection is needed:
const branches = useMemo(() => { // Show when there are unpushed commits, no remote tracking branch, or uncommitted changes
if (!branchesData?.branches) return []; // (uncommitted changes will be committed first, then pushed)
return branchesData.branches.map((b) => b.name).filter((name) => name !== worktree?.branch); const branchHasRemote = branchesData?.hasRemoteBranch ?? false;
}, [branchesData?.branches, worktree?.branch]); const branchAheadCount = branchesData?.aheadCount ?? 0;
const needsPush = !branchHasRemote || branchAheadCount > 0 || !!worktree?.hasChanges;
// Determine the active remote to scope branches to.
// For multi-remote: use the selected target remote.
// For single remote: automatically scope to that remote.
const activeRemote = useMemo(() => {
if (remotes.length === 1) return remotes[0].name;
if (selectedTargetRemote) return selectedTargetRemote;
return '';
}, [remotes, selectedTargetRemote]);
// Filter branches by the active remote and strip remote prefixes for display.
// Returns display names (e.g. "main") without remote prefix.
// Also builds a map from display name → full ref (e.g. "origin/main") for PR creation.
const { branches, branchFullRefMap } = useMemo(() => {
if (!branchesData?.branches)
return { branches: [], branchFullRefMap: new Map<string, string>() };
const refMap = new Map<string, string>();
// If we have an active remote with branch info from the remotes endpoint, use that as the source
const activeRemoteInfo = activeRemote
? remotes.find((r) => r.name === activeRemote)
: undefined;
if (activeRemoteInfo?.branches && activeRemoteInfo.branches.length > 0) {
// Use the remote's branch list — these are already short names (e.g. "main")
const filteredBranches = activeRemoteInfo.branches
.filter((branchName) => {
// Exclude the current worktree branch
return branchName !== worktree?.branch;
})
.map((branchName) => {
// Map display name to full ref
const fullRef = `${activeRemote}/${branchName}`;
refMap.set(branchName, fullRef);
return branchName;
});
return { branches: filteredBranches, branchFullRefMap: refMap };
}
// Fallback: if no remote info available, use the branches from the branches endpoint
// Filter and strip prefixes
const seen = new Set<string>();
const filteredBranches: string[] = [];
for (const b of branchesData.branches) {
// Skip the current worktree branch
if (b.name === worktree?.branch) continue;
if (b.isRemote) {
// Remote branch: check if it belongs to the active remote
const slashIndex = b.name.indexOf('/');
if (slashIndex === -1) continue;
const remoteName = b.name.substring(0, slashIndex);
const branchName = b.name.substring(slashIndex + 1);
// If we have an active remote, only include branches from that remote
if (activeRemote && remoteName !== activeRemote) continue;
// Strip the remote prefix for display
if (!seen.has(branchName)) {
seen.add(branchName);
filteredBranches.push(branchName);
refMap.set(branchName, b.name);
}
} else {
// Local branch — only include if it has a remote counterpart on the active remote
// or if no active remote is set (no remotes at all)
if (!activeRemote) {
if (!seen.has(b.name)) {
seen.add(b.name);
filteredBranches.push(b.name);
refMap.set(b.name, b.name);
}
}
// When active remote is set, skip local-only branches — the remote version
// will be included from the remote branches above
}
}
return { branches: filteredBranches, branchFullRefMap: refMap };
}, [branchesData?.branches, worktree?.branch, activeRemote, remotes]);
// When branches change (e.g. target remote changed), reset base branch if current selection is no longer valid
useEffect(() => {
if (branches.length > 0 && baseBranch && !branches.includes(baseBranch)) {
// Current base branch is not in the filtered list — pick the best match
// Strip any existing remote prefix from the current base branch for comparison
const strippedBaseBranch = baseBranch.includes('/')
? baseBranch.substring(baseBranch.indexOf('/') + 1)
: baseBranch;
// Check if the stripped version exists in the list
if (branches.includes(strippedBaseBranch)) {
setBaseBranch(strippedBaseBranch);
} else {
const mainBranch = branches.find((b) => b === 'main' || b === 'master');
setBaseBranch(mainBranch || branches[0]);
}
}
}, [branches, baseBranch]);
// Fetch remotes when dialog opens // Fetch remotes when dialog opens
const fetchRemotes = useCallback(async () => { const fetchRemotes = useCallback(async () => {
@@ -109,14 +220,15 @@ export function CreatePRDialog({
if (result.success && result.result) { if (result.success && result.result) {
const remoteInfos: RemoteInfo[] = result.result.remotes.map( const remoteInfos: RemoteInfo[] = result.result.remotes.map(
(r: { name: string; url: string }) => ({ (r: { name: string; url: string; branches?: { name: string }[] }) => ({
name: r.name, name: r.name,
url: r.url, url: r.url,
branches: r.branches?.map((b: { name: string }) => b.name) || [],
}) })
); );
setRemotes(remoteInfos); setRemotes(remoteInfos);
// Preserve existing selection if it's still valid; otherwise fall back to 'origin' or first remote // Preserve existing push remote selection if it's still valid; otherwise fall back to 'origin' or first remote
if (remoteInfos.length > 0) { if (remoteInfos.length > 0) {
const remoteNames = remoteInfos.map((r) => r.name); const remoteNames = remoteInfos.map((r) => r.name);
const currentSelection = selectedRemoteRef.current; const currentSelection = selectedRemoteRef.current;
@@ -126,6 +238,19 @@ export function CreatePRDialog({
const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0]; const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0];
setSelectedRemote(defaultRemote.name); setSelectedRemote(defaultRemote.name);
} }
// Preserve existing target remote selection if it's still valid
const currentTargetSelection = selectedTargetRemoteRef.current;
const currentTargetStillExists =
currentTargetSelection !== '' && remoteNames.includes(currentTargetSelection);
if (!currentTargetStillExists) {
// Default target remote: 'upstream' if it exists (fork workflow), otherwise same as push remote
const defaultTarget =
remoteInfos.find((r) => r.name === 'upstream') ||
remoteInfos.find((r) => r.name === 'origin') ||
remoteInfos[0];
setSelectedTargetRemote(defaultTarget.name);
}
} }
} }
} catch { } catch {
@@ -154,6 +279,7 @@ export function CreatePRDialog({
setShowBrowserFallback(false); setShowBrowserFallback(false);
setRemotes([]); setRemotes([]);
setSelectedRemote(''); setSelectedRemote('');
setSelectedTargetRemote('');
setIsGeneratingDescription(false); setIsGeneratingDescription(false);
operationCompletedRef.current = false; operationCompletedRef.current = false;
}, [defaultBaseBranch]); }, [defaultBaseBranch]);
@@ -171,7 +297,12 @@ export function CreatePRDialog({
try { try {
const api = getHttpApiClient(); const api = getHttpApiClient();
const result = await api.worktree.generatePRDescription(worktree.path, baseBranch); // Resolve the display name to the actual branch name for the API
const resolvedRef = branchFullRefMap.get(baseBranch) || baseBranch;
const branchNameForApi = resolvedRef.includes('/')
? resolvedRef.substring(resolvedRef.indexOf('/') + 1)
: resolvedRef;
const result = await api.worktree.generatePRDescription(worktree.path, branchNameForApi);
if (result.success) { if (result.success) {
if (result.title) { if (result.title) {
@@ -207,14 +338,27 @@ export function CreatePRDialog({
setError('Worktree API not available'); setError('Worktree API not available');
return; return;
} }
// Resolve the display branch name to the full ref for the API call.
// The baseBranch state holds the display name (e.g. "main"), but the API
// may need the short name without the remote prefix. We pass the display name
// since the backend handles branch resolution. However, if the full ref is
// available, we can use it for more precise targeting.
const resolvedBaseBranch = branchFullRefMap.get(baseBranch) || baseBranch;
// Strip the remote prefix from the resolved ref for the API call
// (e.g. "origin/main" → "main") since the backend expects the branch name only
const baseBranchForApi = resolvedBaseBranch.includes('/')
? resolvedBaseBranch.substring(resolvedBaseBranch.indexOf('/') + 1)
: resolvedBaseBranch;
const result = await api.worktree.createPR(worktree.path, { const result = await api.worktree.createPR(worktree.path, {
projectPath: projectPath || undefined, projectPath: projectPath || undefined,
commitMessage: commitMessage || undefined, commitMessage: commitMessage || undefined,
prTitle: title || worktree.branch, prTitle: title || worktree.branch,
prBody: body || `Changes from branch ${worktree.branch}`, prBody: body || `Changes from branch ${worktree.branch}`,
baseBranch, baseBranch: baseBranchForApi,
draft: isDraft, draft: isDraft,
remote: selectedRemote || undefined, remote: selectedRemote || undefined,
targetRemote: remotes.length > 1 ? selectedTargetRemote || undefined : undefined,
}); });
if (result.success && result.result) { if (result.success && result.result) {
@@ -348,7 +492,7 @@ export function CreatePRDialog({
Create Pull Request Create Pull Request
</DialogTitle> </DialogTitle>
<DialogDescription className="break-words"> <DialogDescription className="break-words">
Push changes and create a pull request from{' '} {worktree.hasChanges ? 'Push changes and create' : 'Create'} a pull request from{' '}
<code className="font-mono bg-muted px-1 rounded break-all">{worktree.branch}</code> <code className="font-mono bg-muted px-1 rounded break-all">{worktree.branch}</code>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -482,8 +626,8 @@ export function CreatePRDialog({
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Remote selector - only show if multiple remotes are available */} {/* Push remote selector - only show when multiple remotes and there are commits to push */}
{remotes.length > 1 && ( {remotes.length > 1 && needsPush && (
<div className="grid gap-2"> <div className="grid gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="remote-select">Push to Remote</Label> <Label htmlFor="remote-select">Push to Remote</Label>
@@ -525,14 +669,50 @@ export function CreatePRDialog({
</div> </div>
)} )}
{/* Target remote selector - which remote to create PR against */}
{remotes.length > 1 && (
<div className="grid gap-2">
<Label htmlFor="target-remote-select">Create PR Against</Label>
<Select value={selectedTargetRemote} onValueChange={setSelectedTargetRemote}>
<SelectTrigger id="target-remote-select">
<SelectValue placeholder="Select target remote" />
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem
key={remote.name}
value={remote.name}
description={
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
}
>
<span className="font-medium">{remote.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
The remote repository where the pull request will be created
</p>
</div>
)}
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="base-branch">Base Branch</Label> <Label htmlFor="base-branch">Base Remote Branch</Label>
<BranchAutocomplete <BranchAutocomplete
value={baseBranch} value={baseBranch}
onChange={setBaseBranch} onChange={setBaseBranch}
branches={branches} branches={branches}
placeholder="Select base branch..." placeholder="Select base branch..."
disabled={isLoadingBranches} disabled={isLoadingBranches || isLoadingRemotes}
allowCreate={false}
emptyMessage={
activeRemote
? `No branches found on remote "${activeRemote}".`
: 'No matching branches found.'
}
data-testid="base-branch-autocomplete" data-testid="base-branch-autocomplete"
/> />
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -10,9 +10,11 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { GitBranch, AlertCircle } from 'lucide-react'; import { GitBranch, AlertCircle, ChevronDown, ChevronRight, Globe, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
import { toast } from 'sonner'; import { toast } from 'sonner';
/** /**
@@ -100,6 +102,145 @@ export function CreateWorktreeDialog({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<{ title: string; description?: string } | null>(null); const [error, setError] = useState<{ title: string; description?: string } | null>(null);
// Base branch selection state
const [showBaseBranch, setShowBaseBranch] = useState(false);
const [baseBranch, setBaseBranch] = useState('');
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [availableBranches, setAvailableBranches] = useState<
Array<{ name: string; isRemote: boolean }>
>([]);
// When the branch list fetch fails, store a message to show the user and
// allow free-form branch entry via allowCreate as a fallback.
const [branchFetchError, setBranchFetchError] = useState<string | null>(null);
// AbortController ref so in-flight branch fetches can be cancelled when the dialog closes
const branchFetchAbortRef = useRef<AbortController | null>(null);
// Fetch available branches (local + remote) when the base branch section is expanded
const fetchBranches = useCallback(
async (signal?: AbortSignal) => {
if (!projectPath) return;
setIsLoadingBranches(true);
try {
const api = getHttpApiClient();
// Fetch branches using the project path (use listBranches on the project root).
// Pass the AbortSignal so controller.abort() cancels the in-flight HTTP request.
const branchResult = await api.worktree.listBranches(projectPath, true, signal);
// If the fetch was aborted while awaiting, bail out to avoid stale state writes
if (signal?.aborted) return;
if (branchResult.success && branchResult.result) {
setBranchFetchError(null);
setAvailableBranches(
branchResult.result.branches.map((b: { name: string; isRemote: boolean }) => ({
name: b.name,
isRemote: b.isRemote,
}))
);
} else {
// API returned success: false — treat as an error
const message =
branchResult.error || 'Failed to load branches. You can type a branch name manually.';
setBranchFetchError(message);
setAvailableBranches([{ name: 'main', isRemote: false }]);
}
} catch (err) {
// If aborted, don't update state
if (signal?.aborted) return;
const message =
err instanceof Error
? err.message
: 'Failed to load branches. You can type a branch name manually.';
setBranchFetchError(message);
// Provide 'main' as a safe fallback so the autocomplete is not empty,
// and enable free-form entry (allowCreate) so the user can still type
// any branch name when the remote list is unavailable.
setAvailableBranches([{ name: 'main', isRemote: false }]);
} finally {
if (!signal?.aborted) {
setIsLoadingBranches(false);
}
}
},
[projectPath]
);
// Fetch branches when the base branch section is expanded
useEffect(() => {
if (open && showBaseBranch) {
// Abort any previous in-flight fetch
branchFetchAbortRef.current?.abort();
const controller = new AbortController();
branchFetchAbortRef.current = controller;
fetchBranches(controller.signal);
}
return () => {
branchFetchAbortRef.current?.abort();
branchFetchAbortRef.current = null;
};
}, [open, showBaseBranch, fetchBranches]);
// Reset state when dialog closes
useEffect(() => {
if (!open) {
// Abort any in-flight branch fetch to prevent stale writes
branchFetchAbortRef.current?.abort();
branchFetchAbortRef.current = null;
setBranchName('');
setBaseBranch('');
setShowBaseBranch(false);
setError(null);
setAvailableBranches([]);
setBranchFetchError(null);
setIsLoadingBranches(false);
}
}, [open]);
// Build branch name list for the autocomplete, with local branches first then remote
const branchNames = useMemo(() => {
const local: string[] = [];
const remote: string[] = [];
for (const b of availableBranches) {
if (b.isRemote) {
// Skip bare remote refs without a branch name (e.g. "origin" by itself)
if (!b.name.includes('/')) continue;
remote.push(b.name);
} else {
local.push(b.name);
}
}
// Local branches first, then remote branches
return [...local, ...remote];
}, [availableBranches]);
// Determine if the selected base branch is a remote branch.
// Also detect manually entered remote-style names (e.g. "origin/feature")
// so the UI shows the "Remote branch — will fetch latest" hint even when
// the branch isn't in the fetched availableBranches list.
const isRemoteBaseBranch = useMemo(() => {
if (!baseBranch) return false;
// If the branch list couldn't be fetched, availableBranches is a fallback
// and may not reflect reality — suppress the remote hint to avoid misleading the user.
if (branchFetchError) return false;
// Check fetched branch list first
const knownRemote = availableBranches.some((b) => b.name === baseBranch && b.isRemote);
if (knownRemote) return true;
// Heuristic: if the branch contains '/' and isn't a known local branch,
// treat it as a remote ref (e.g. "origin/main")
if (baseBranch.includes('/')) {
const isKnownLocal = availableBranches.some((b) => b.name === baseBranch && !b.isRemote);
return !isKnownLocal;
}
return false;
}, [baseBranch, availableBranches, branchFetchError]);
const handleCreate = async () => { const handleCreate = async () => {
if (!branchName.trim()) { if (!branchName.trim()) {
setError({ title: 'Branch name is required' }); setError({ title: 'Branch name is required' });
@@ -116,6 +257,17 @@ export function CreateWorktreeDialog({
return; return;
} }
// Validate baseBranch using the same allowed-character check as branchName to prevent
// shell-special characters or invalid git ref names from reaching the API.
const trimmedBaseBranch = baseBranch.trim();
if (trimmedBaseBranch && !validBranchRegex.test(trimmedBaseBranch)) {
setError({
title: 'Invalid base branch name',
description: 'Use only letters, numbers, dots, underscores, hyphens, and slashes.',
});
return;
}
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
@@ -125,15 +277,22 @@ export function CreateWorktreeDialog({
setError({ title: 'Worktree API not available' }); setError({ title: 'Worktree API not available' });
return; return;
} }
const result = await api.worktree.create(projectPath, branchName);
// Pass the validated baseBranch if one was selected (otherwise defaults to HEAD)
const effectiveBaseBranch = trimmedBaseBranch || undefined;
const result = await api.worktree.create(projectPath, branchName, effectiveBaseBranch);
if (result.success && result.worktree) { if (result.success && result.worktree) {
const baseDesc = effectiveBaseBranch ? ` from ${effectiveBaseBranch}` : '';
toast.success(`Worktree created for branch "${result.worktree.branch}"`, { toast.success(`Worktree created for branch "${result.worktree.branch}"`, {
description: result.worktree.isNew ? 'New branch created' : 'Using existing branch', description: result.worktree.isNew
? `New branch created${baseDesc}`
: 'Using existing branch',
}); });
onCreated({ path: result.worktree.path, branch: result.worktree.branch }); onCreated({ path: result.worktree.path, branch: result.worktree.branch });
onOpenChange(false); onOpenChange(false);
setBranchName(''); setBranchName('');
setBaseBranch('');
} else { } else {
setError(parseWorktreeError(result.error || 'Failed to create worktree')); setError(parseWorktreeError(result.error || 'Failed to create worktree'));
} }
@@ -154,7 +313,7 @@ export function CreateWorktreeDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]"> <DialogContent className="sm:max-w-[480px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<GitBranch className="w-5 h-5" /> <GitBranch className="w-5 h-5" />
@@ -181,19 +340,96 @@ export function CreateWorktreeDialog({
className="font-mono text-sm" className="font-mono text-sm"
autoFocus autoFocus
/> />
{error && ( </div>
<div className="flex items-start gap-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" /> {/* Base Branch Section - collapsible */}
<div className="space-y-1"> <div className="grid gap-2">
<p className="text-sm font-medium text-destructive">{error.title}</p> <button
{error.description && ( type="button"
<p className="text-xs text-destructive/80">{error.description}</p> onClick={() => setShowBaseBranch(!showBaseBranch)}
)} className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
>
{showBaseBranch ? (
<ChevronDown className="w-3.5 h-3.5" />
) : (
<ChevronRight className="w-3.5 h-3.5" />
)}
<span>Base Branch</span>
{baseBranch && !showBaseBranch && (
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono ml-1">
{baseBranch}
</code>
)}
</button>
{showBaseBranch && (
<div className="grid gap-2 pl-1">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Select a local or remote branch as the starting point
</p>
<Button
variant="ghost"
size="sm"
onClick={() => {
branchFetchAbortRef.current?.abort();
const controller = new AbortController();
branchFetchAbortRef.current = controller;
void fetchBranches(controller.signal);
}}
disabled={isLoadingBranches}
className="h-6 px-2 text-xs"
>
{isLoadingBranches ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Refresh
</Button>
</div> </div>
{branchFetchError && (
<div className="flex items-center gap-1.5 text-xs text-destructive">
<AlertCircle className="w-3 h-3 flex-shrink-0" />
<span>Could not load branches: {branchFetchError}</span>
</div>
)}
<BranchAutocomplete
value={baseBranch}
onChange={(value) => {
setBaseBranch(value);
setError(null);
}}
branches={branchNames}
placeholder="Select base branch (default: HEAD)..."
disabled={isLoadingBranches}
allowCreate={!!branchFetchError}
/>
{isRemoteBaseBranch && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Globe className="w-3 h-3" />
<span>Remote branch will fetch latest before creating worktree</span>
</div>
)}
</div> </div>
)} )}
</div> </div>
{error && (
<div className="flex items-start gap-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">{error.title}</p>
{error.description && (
<p className="text-xs text-destructive/80">{error.description}</p>
)}
</div>
</div>
)}
<div className="text-xs text-muted-foreground space-y-1"> <div className="text-xs text-muted-foreground space-y-1">
<p>Examples:</p> <p>Examples:</p>
<ul className="list-disc list-inside pl-2 space-y-0.5"> <ul className="list-disc list-inside pl-2 space-y-0.5">
@@ -218,7 +454,7 @@ export function CreateWorktreeDialog({
{isLoading ? ( {isLoading ? (
<> <>
<Spinner size="sm" className="mr-2" /> <Spinner size="sm" className="mr-2" />
Creating... {isRemoteBaseBranch ? 'Fetching & Creating...' : 'Creating...'}
</> </>
) : ( ) : (
<> <>

View File

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

View File

@@ -24,8 +24,8 @@ interface MergeWorktreeDialogProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
projectPath: string; projectPath: string;
worktree: WorktreeInfo | null; worktree: WorktreeInfo | null;
/** Called when merge is successful. deletedBranch indicates if the branch was also deleted. */ /** Called when integration is successful. integratedWorktree indicates the integrated worktree and deletedBranch indicates if the branch was also deleted. */
onMerged: (mergedWorktree: WorktreeInfo, deletedBranch: boolean) => void; onIntegrated: (integratedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void; onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
} }
@@ -34,7 +34,7 @@ export function MergeWorktreeDialog({
onOpenChange, onOpenChange,
projectPath, projectPath,
worktree, worktree,
onMerged, onIntegrated,
onCreateConflictResolutionFeature, onCreateConflictResolutionFeature,
}: MergeWorktreeDialogProps) { }: MergeWorktreeDialogProps) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -105,10 +105,10 @@ export function MergeWorktreeDialog({
if (result.success) { if (result.success) {
const description = deleteWorktreeAndBranch const description = deleteWorktreeAndBranch
? `Branch "${worktree.branch}" has been merged into "${targetBranch}" and the worktree and branch were deleted` ? `Branch "${worktree.branch}" has been integrated into "${targetBranch}" and the worktree and branch were deleted`
: `Branch "${worktree.branch}" has been merged into "${targetBranch}"`; : `Branch "${worktree.branch}" has been integrated into "${targetBranch}"`;
toast.success(`Branch merged to ${targetBranch}`, { description }); toast.success(`Branch integrated into ${targetBranch}`, { description });
onMerged(worktree, deleteWorktreeAndBranch); onIntegrated(worktree, deleteWorktreeAndBranch);
onOpenChange(false); onOpenChange(false);
} else { } else {
// Check if the error indicates merge conflicts // Check if the error indicates merge conflicts
@@ -128,11 +128,11 @@ export function MergeWorktreeDialog({
conflictFiles: result.conflictFiles || [], conflictFiles: result.conflictFiles || [],
operationType: 'merge', operationType: 'merge',
}); });
toast.error('Merge conflicts detected', { toast.error('Integrate conflicts detected', {
description: 'Choose how to resolve the conflicts below.', description: 'Choose how to resolve the conflicts below.',
}); });
} else { } else {
toast.error('Failed to merge branch', { toast.error('Failed to integrate branch', {
description: result.error, description: result.error,
}); });
} }
@@ -153,11 +153,11 @@ export function MergeWorktreeDialog({
conflictFiles: [], conflictFiles: [],
operationType: 'merge', operationType: 'merge',
}); });
toast.error('Merge conflicts detected', { toast.error('Integrate conflicts detected', {
description: 'Choose how to resolve the conflicts below.', description: 'Choose how to resolve the conflicts below.',
}); });
} else { } else {
toast.error('Failed to merge branch', { toast.error('Failed to integrate branch', {
description: errorMessage, description: errorMessage,
}); });
} }
@@ -191,12 +191,12 @@ export function MergeWorktreeDialog({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" /> <AlertTriangle className="w-5 h-5 text-orange-500" />
Merge Conflicts Detected Integrate Conflicts Detected
</DialogTitle> </DialogTitle>
<DialogDescription asChild> <DialogDescription asChild>
<div className="space-y-4"> <div className="space-y-4">
<span className="block"> <span className="block">
There are conflicts when merging{' '} There are conflicts when integrating{' '}
<code className="font-mono bg-muted px-1 rounded"> <code className="font-mono bg-muted px-1 rounded">
{mergeConflict.sourceBranch} {mergeConflict.sourceBranch}
</code>{' '} </code>{' '}
@@ -274,12 +274,12 @@ export function MergeWorktreeDialog({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<GitMerge className="w-5 h-5 text-green-600" /> <GitMerge className="w-5 h-5 text-green-600" />
Merge Branch Integrate Branch
</DialogTitle> </DialogTitle>
<DialogDescription asChild> <DialogDescription asChild>
<div className="space-y-4"> <div className="space-y-4">
<span className="block"> <span className="block">
Merge <code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>{' '} Integrate <code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>{' '}
into: into:
</span> </span>
@@ -308,7 +308,7 @@ export function MergeWorktreeDialog({
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" /> <AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
<span className="text-yellow-500 text-sm"> <span className="text-yellow-500 text-sm">
This worktree has {worktree.changedFilesCount} uncommitted change(s). Please This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
commit or discard them before merging. commit or discard them before integrating.
</span> </span>
</div> </div>
)} )}
@@ -327,7 +327,7 @@ export function MergeWorktreeDialog({
className="text-sm cursor-pointer flex items-center gap-1.5" className="text-sm cursor-pointer flex items-center gap-1.5"
> >
<Trash2 className="w-3.5 h-3.5 text-destructive" /> <Trash2 className="w-3.5 h-3.5 text-destructive" />
Delete worktree and branch after merging Delete worktree and branch after integrating
</Label> </Label>
</div> </div>
@@ -353,12 +353,12 @@ export function MergeWorktreeDialog({
{isLoading ? ( {isLoading ? (
<> <>
<Spinner size="sm" variant="foreground" className="mr-2" /> <Spinner size="sm" variant="foreground" className="mr-2" />
Merging... Integrating...
</> </>
) : ( ) : (
<> <>
<GitMerge className="w-4 h-4 mr-2" /> <GitMerge className="w-4 h-4 mr-2" />
Merge Integrate
</> </>
)} )}
</Button> </Button>

View File

@@ -0,0 +1,190 @@
/**
* Post-Merge Prompt Dialog
*
* Shown after a pull or stash apply results in a clean merge (no conflicts).
* Presents the user with two options:
* 1. Commit the merge — automatically stage all merge-result files and open commit dialog
* 2. Merge manually — leave the working tree as-is for manual review
*
* The user's choice can be persisted as a preference to avoid repeated prompts.
*/
import { useState, useCallback, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { GitMerge, GitCommitHorizontal, FileText, Settings } from 'lucide-react';
import { cn } from '@/lib/utils';
export type MergePostAction = 'commit' | 'manual' | null;
interface PostMergePromptDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Branch name where the merge happened */
branchName: string;
/** Number of files affected by the merge */
mergeFileCount: number;
/** List of files affected by the merge */
mergeAffectedFiles?: string[];
/** Called when the user chooses to commit the merge */
onCommitMerge: () => void;
/** Called when the user chooses to handle the merge manually */
onMergeManually: () => void;
/** Current saved preference (null = ask every time) */
savedPreference?: MergePostAction;
/** Called when the user changes the preference */
onSavePreference?: (preference: MergePostAction) => void;
}
export function PostMergePromptDialog({
open,
onOpenChange,
branchName,
mergeFileCount,
mergeAffectedFiles,
onCommitMerge,
onMergeManually,
savedPreference,
onSavePreference,
}: PostMergePromptDialogProps) {
const [rememberChoice, setRememberChoice] = useState(false);
const [showFiles, setShowFiles] = useState(false);
// Reset transient state each time the dialog is opened
useEffect(() => {
if (open) {
setRememberChoice(false);
setShowFiles(false);
}
}, [open]);
const handleCommitMerge = useCallback(() => {
if (rememberChoice && onSavePreference) {
onSavePreference('commit');
}
onCommitMerge();
onOpenChange(false);
}, [rememberChoice, onSavePreference, onCommitMerge, onOpenChange]);
const handleMergeManually = useCallback(() => {
if (rememberChoice && onSavePreference) {
onSavePreference('manual');
}
onMergeManually();
onOpenChange(false);
}, [rememberChoice, onSavePreference, onMergeManually, onOpenChange]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px] w-full max-w-full sm:rounded-xl rounded-none dialog-fullscreen-mobile">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitMerge className="w-5 h-5 text-purple-500" />
Merge Complete
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<span className="block">
A merge was successfully completed on{' '}
<code className="font-mono bg-muted px-1 rounded">{branchName}</code>
{mergeFileCount > 0 && (
<span>
{' '}
affecting {mergeFileCount} file{mergeFileCount !== 1 ? 's' : ''}
</span>
)}
. How would you like to proceed?
</span>
{mergeAffectedFiles && mergeAffectedFiles.length > 0 && (
<div>
<button
onClick={() => setShowFiles(!showFiles)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
>
<FileText className="w-3 h-3" />
{showFiles ? 'Hide' : 'Show'} affected files ({mergeAffectedFiles.length})
</button>
{showFiles && (
<div className="mt-1.5 border border-border rounded-lg overflow-hidden max-h-[150px] overflow-y-auto scrollbar-visible">
{mergeAffectedFiles.map((file) => (
<div
key={file}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
>
<GitMerge className="w-3 h-3 text-purple-500 flex-shrink-0" />
<span className="truncate">{file}</span>
</div>
))}
</div>
)}
</div>
)}
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground font-medium mb-2">
Choose how to proceed:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
<strong>Commit Merge</strong> &mdash; Stage all merge files and open the commit
dialog with a pre-populated merge commit message
</li>
<li>
<strong>Review Manually</strong> &mdash; Leave the working tree as-is so you can
review changes and commit at your own pace
</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
{/* Remember choice option */}
{onSavePreference && (
<div className="flex items-center gap-2 px-1">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<Checkbox
checked={rememberChoice}
onCheckedChange={(checked) => setRememberChoice(checked)}
className="rounded border-border"
/>
<Settings className="w-3 h-3" />
Remember my choice for future merges
</label>
{savedPreference && (
<button
onClick={() => onSavePreference(null)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors ml-auto"
>
Reset preference
</button>
)}
</div>
)}
<DialogFooter className={cn('flex-col sm:flex-row gap-2')}>
<Button variant="outline" onClick={handleMergeManually} className="w-full sm:w-auto">
<FileText className="w-4 h-4 mr-2" />
Review Manually
</Button>
<Button
onClick={handleCommitMerge}
className="w-full sm:w-auto bg-purple-600 hover:bg-purple-700 text-white"
>
<GitCommitHorizontal className="w-4 h-4 mr-2" />
Commit Merge
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

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

View File

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

View File

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

View File

@@ -94,8 +94,6 @@ export function useBoardActions({
skipVerificationInAutoMode, skipVerificationInAutoMode,
isPrimaryWorktreeBranch, isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch, getPrimaryWorktreeBranch,
getAutoModeState,
getMaxConcurrencyForWorktree,
} = useAppStore(); } = useAppStore();
const autoMode = useAutoMode(); const autoMode = useAutoMode();
@@ -123,6 +121,7 @@ export function useBoardActions({
dependencies?: string[]; dependencies?: string[];
childDependencies?: string[]; // Feature IDs that should depend on this feature childDependencies?: string[]; // Feature IDs that should depend on this feature
workMode?: 'current' | 'auto' | 'custom'; workMode?: 'current' | 'auto' | 'custom';
initialStatus?: 'backlog' | 'in_progress'; // Skip backlog flash when creating & starting immediately
}) => { }) => {
const workMode = featureData.workMode || 'current'; const workMode = featureData.workMode || 'current';
@@ -218,13 +217,15 @@ export function useBoardActions({
const needsTitleGeneration = const needsTitleGeneration =
!titleWasGenerated && !featureData.title.trim() && featureData.description.trim(); !titleWasGenerated && !featureData.title.trim() && featureData.description.trim();
const initialStatus = featureData.initialStatus || 'backlog';
const newFeatureData = { const newFeatureData = {
...featureData, ...featureData,
title: titleWasGenerated ? titleForBranch : featureData.title, title: titleWasGenerated ? titleForBranch : featureData.title,
titleGenerating: needsTitleGeneration, titleGenerating: needsTitleGeneration,
status: 'backlog' as const, status: initialStatus,
branchName: finalBranchName, branchName: finalBranchName,
dependencies: featureData.dependencies || [], dependencies: featureData.dependencies || [],
...(initialStatus === 'in_progress' ? { startedAt: new Date().toISOString() } : {}),
}; };
const createdFeature = addFeature(newFeatureData); const createdFeature = addFeature(newFeatureData);
// Must await to ensure feature exists on server before user can drag it // Must await to ensure feature exists on server before user can drag it
@@ -558,38 +559,9 @@ export function useBoardActions({
const handleStartImplementation = useCallback( const handleStartImplementation = useCallback(
async (feature: Feature) => { async (feature: Feature) => {
// Check capacity for the feature's specific worktree, not the current view // Note: No concurrency limit check here. Manual feature starts should never
// Normalize the branch name: if the feature's branch is the primary worktree branch, // be blocked by the auto mode concurrency limit. The concurrency limit only
// treat it as null (main worktree) to match how running tasks are stored // governs how many features the auto-loop picks up automatically.
const rawBranchName = feature.branchName ?? null;
const featureBranchName =
currentProject?.path &&
rawBranchName &&
isPrimaryWorktreeBranch(currentProject.path, rawBranchName)
? null
: rawBranchName;
const featureWorktreeState = currentProject
? getAutoModeState(currentProject.id, featureBranchName)
: null;
// Use getMaxConcurrencyForWorktree which correctly falls back to global maxConcurrency
// instead of autoMode.maxConcurrency which only falls back to DEFAULT_MAX_CONCURRENCY (1)
const featureMaxConcurrency = currentProject
? getMaxConcurrencyForWorktree(currentProject.id, featureBranchName)
: autoMode.maxConcurrency;
const featureRunningCount = featureWorktreeState?.runningTasks?.length ?? 0;
const canStartInWorktree = featureRunningCount < featureMaxConcurrency;
if (!canStartInWorktree) {
const worktreeDesc = featureBranchName
? `worktree "${featureBranchName}"`
: 'main worktree';
toast.error('Concurrency limit reached', {
description: `${worktreeDesc} can only have ${featureMaxConcurrency} task${
featureMaxConcurrency > 1 ? 's' : ''
} running at a time. Wait for a task to complete or increase the limit.`,
});
return false;
}
// Check for blocking dependencies and show warning if enabled // Check for blocking dependencies and show warning if enabled
if (enableDependencyBlocking) { if (enableDependencyBlocking) {
@@ -608,20 +580,51 @@ export function useBoardActions({
} }
} }
const updates = { // Skip status update if feature was already created with in_progress status
status: 'in_progress' as const, // (e.g., via "Make" button which creates directly as in_progress to avoid backlog flash)
startedAt: new Date().toISOString(), const alreadyInProgress = feature.status === 'in_progress';
};
updateFeature(feature.id, updates); if (!alreadyInProgress) {
const updates = {
status: 'in_progress' as const,
startedAt: new Date().toISOString(),
};
updateFeature(feature.id, updates);
try {
// Must await to ensure feature status is persisted before starting agent
await persistFeatureUpdate(feature.id, updates);
} catch (error) {
// Rollback to backlog if persist fails (e.g., server offline)
logger.error('Failed to update feature status, rolling back to backlog:', error);
const rollbackUpdates = {
status: 'backlog' as const,
startedAt: undefined,
};
updateFeature(feature.id, rollbackUpdates);
persistFeatureUpdate(feature.id, rollbackUpdates).catch((persistError) => {
logger.error('Failed to persist rollback:', persistError);
});
if (isConnectionError(error)) {
handleServerOffline();
return false;
}
toast.error('Failed to start feature', {
description:
error instanceof Error ? error.message : 'Server may be offline. Please try again.',
});
return false;
}
}
try { try {
// Must await to ensure feature status is persisted before starting agent
await persistFeatureUpdate(feature.id, updates);
logger.info('Feature moved to in_progress, starting agent...'); logger.info('Feature moved to in_progress, starting agent...');
await handleRunFeature(feature); await handleRunFeature(feature);
return true; return true;
} catch (error) { } catch (error) {
// Rollback to backlog if persist or run fails (e.g., server offline) // Rollback to backlog if run fails
logger.error('Failed to start feature, rolling back to backlog:', error); logger.error('Failed to start feature, rolling back to backlog:', error);
const rollbackUpdates = { const rollbackUpdates = {
status: 'backlog' as const, status: 'backlog' as const,
@@ -647,18 +650,7 @@ export function useBoardActions({
return false; return false;
} }
}, },
[ [enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature]
autoMode,
enableDependencyBlocking,
features,
updateFeature,
persistFeatureUpdate,
handleRunFeature,
currentProject,
getAutoModeState,
getMaxConcurrencyForWorktree,
isPrimaryWorktreeBranch,
]
); );
const handleVerifyFeature = useCallback( const handleVerifyFeature = useCallback(

View File

@@ -12,6 +12,7 @@ type ColumnId = Feature['status'];
interface UseBoardColumnFeaturesProps { interface UseBoardColumnFeaturesProps {
features: Feature[]; features: Feature[];
runningAutoTasks: string[]; runningAutoTasks: string[];
runningAutoTasksAllWorktrees: string[]; // Running tasks across ALL worktrees (prevents backlog flash during event timing gaps)
searchQuery: string; searchQuery: string;
currentWorktreePath: string | null; // Currently selected worktree path currentWorktreePath: string | null; // Currently selected worktree path
currentWorktreeBranch: string | null; // Branch name of the selected worktree (null = main) currentWorktreeBranch: string | null; // Branch name of the selected worktree (null = main)
@@ -21,6 +22,7 @@ interface UseBoardColumnFeaturesProps {
export function useBoardColumnFeatures({ export function useBoardColumnFeatures({
features, features,
runningAutoTasks, runningAutoTasks,
runningAutoTasksAllWorktrees,
searchQuery, searchQuery,
currentWorktreePath, currentWorktreePath,
currentWorktreeBranch, currentWorktreeBranch,
@@ -38,6 +40,10 @@ export function useBoardColumnFeatures({
}; };
const featureMap = createFeatureMap(features); const featureMap = createFeatureMap(features);
const runningTaskIds = new Set(runningAutoTasks); const runningTaskIds = new Set(runningAutoTasks);
// Track ALL running tasks across all worktrees to prevent features from
// briefly appearing in backlog during the timing gap between when the server
// starts executing a feature and when the UI receives the event/status update.
const allRunningTaskIds = new Set(runningAutoTasksAllWorktrees);
// Filter features by search query (case-insensitive) // Filter features by search query (case-insensitive)
const normalizedQuery = searchQuery.toLowerCase().trim(); const normalizedQuery = searchQuery.toLowerCase().trim();
@@ -138,11 +144,28 @@ export function useBoardColumnFeatures({
return; return;
} }
// Not running: place by status (and worktree filter) // Not running (on this worktree): place by status (and worktree filter)
// Filter all items by worktree, including backlog // Filter all items by worktree, including backlog
// This ensures backlog items with a branch assigned only show in that branch // This ensures backlog items with a branch assigned only show in that branch
if (status === 'backlog') { //
if (matchesWorktree) { // 'ready' and 'interrupted' are transitional statuses that don't have dedicated columns:
// - 'ready': Feature has an approved plan, waiting to be picked up for execution
// - 'interrupted': Feature execution was aborted (e.g., user stopped it, server restart)
// Both display in the backlog column and need the same allRunningTaskIds race-condition
// protection as 'backlog' to prevent briefly flashing in backlog when already executing.
if (status === 'backlog' || status === 'ready' || status === 'interrupted') {
// IMPORTANT: Check if this feature is running on ANY worktree before placing in backlog.
// This prevents a race condition where the feature has started executing on the server
// (and is tracked in a different worktree's running list) but the disk status hasn't
// been updated yet or the UI hasn't received the worktree-scoped event.
// In that case, the feature would briefly flash in the backlog column.
if (allRunningTaskIds.has(f.id)) {
// Feature is running somewhere - show in in_progress if it matches this worktree,
// otherwise skip it (it will appear on the correct worktree's board)
if (matchesWorktree) {
map.in_progress.push(f);
}
} else if (matchesWorktree) {
map.backlog.push(f); map.backlog.push(f);
} }
} else if (map[status]) { } else if (map[status]) {
@@ -159,8 +182,12 @@ export function useBoardColumnFeatures({
map[status].push(f); map[status].push(f);
} }
} else { } else {
// Unknown status, default to backlog // Unknown status - apply same allRunningTaskIds protection and default to backlog
if (matchesWorktree) { if (allRunningTaskIds.has(f.id)) {
if (matchesWorktree) {
map.in_progress.push(f);
}
} else if (matchesWorktree) {
map.backlog.push(f); map.backlog.push(f);
} }
} }
@@ -199,6 +226,7 @@ export function useBoardColumnFeatures({
}, [ }, [
features, features,
runningAutoTasks, runningAutoTasks,
runningAutoTasksAllWorktrees,
searchQuery, searchQuery,
currentWorktreePath, currentWorktreePath,
currentWorktreeBranch, currentWorktreeBranch,

View File

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

View File

@@ -6,7 +6,7 @@
*/ */
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient, useIsRestoring } from '@tanstack/react-query';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -24,13 +24,24 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [persistedCategories, setPersistedCategories] = useState<string[]>([]); const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
// Track whether React Query's IDB persistence layer is still restoring.
// During the restore window (~100-500ms on mobile), queries report
// isLoading=true because no data is in the cache yet. We suppress
// the full-screen spinner during this period to avoid a visible flash
// on PWA memory-eviction cold starts.
const isRestoring = useIsRestoring();
// Use React Query for features // Use React Query for features
const { const {
data: features = [], data: features = [],
isLoading, isLoading: isQueryLoading,
refetch: loadFeatures, refetch: loadFeatures,
} = useFeatures(currentProject?.path); } = useFeatures(currentProject?.path);
// Don't report loading while IDB cache restore is in progress —
// features will appear momentarily once the restore completes.
const isLoading = isQueryLoading && !isRestoring;
// Load persisted categories from file // Load persisted categories from file
const loadCategories = useCallback(async () => { const loadCategories = useCallback(async () => {
if (!currentProject) return; if (!currentProject) return;

View File

@@ -320,13 +320,13 @@ export function KanbanBoard({
return ( return (
<div <div
className={cn( className={cn(
'flex-1 overflow-x-auto px-5 pt-2 sm:pt-4 pb-1 sm:pb-4 relative', 'flex-1 overflow-x-auto px-5 pt-2 sm:pt-4 pb-0 sm:pb-4 relative',
'transition-opacity duration-200', 'transition-opacity duration-200',
className className
)} )}
style={backgroundImageStyle} style={backgroundImageStyle}
> >
<div className="h-full py-1" style={containerStyle}> <div className="h-full pt-1 pb-0 sm:pb-1" style={containerStyle}>
{columns.map((column) => { {columns.map((column) => {
const columnFeatures = getColumnFeatures(column.id as ColumnId); const columnFeatures = getColumnFeatures(column.id as ColumnId);
return ( return (

View File

@@ -131,12 +131,12 @@ export function DevServerLogsPanel({
return ( return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}> <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent <DialogContent
className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden" className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden dialog-fullscreen-mobile"
data-testid="dev-server-logs-panel" data-testid="dev-server-logs-panel"
compact compact
> >
{/* Compact Header */} {/* Compact Header */}
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12"> <DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12 dialog-compact-header-mobile">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2 text-base"> <DialogTitle className="flex items-center gap-2 text-base">
<Terminal className="w-4 h-4 text-primary" /> <Terminal className="w-4 h-4 text-primary" />

View File

@@ -40,6 +40,7 @@ import {
AlertTriangle, AlertTriangle,
XCircle, XCircle,
CheckCircle, CheckCircle,
Settings2,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -54,6 +55,7 @@ import {
import { getEditorIcon } from '@/components/icons/editor-icons'; import { getEditorIcon } from '@/components/icons/editor-icons';
import { getTerminalIcon } from '@/components/icons/terminal-icons'; import { getTerminalIcon } from '@/components/icons/terminal-icons';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import type { TerminalScript } from '@/components/views/project-settings-view/terminal-scripts-constants';
interface WorktreeActionsDropdownProps { interface WorktreeActionsDropdownProps {
worktree: WorktreeInfo; worktree: WorktreeInfo;
@@ -81,10 +83,18 @@ interface WorktreeActionsDropdownProps {
isTestRunning?: boolean; isTestRunning?: boolean;
/** Active test session info for this worktree */ /** Active test session info for this worktree */
testSessionInfo?: TestSessionInfo; testSessionInfo?: TestSessionInfo;
/** List of available remotes for this worktree (used to show remote submenu) */
remotes?: Array<{ name: string; url: string }>;
/** The name of the remote that the current branch is tracking (e.g. "origin"), if any */
trackingRemote?: string;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void; onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void; onPush: (worktree: WorktreeInfo) => void;
onPushNewBranch: (worktree: WorktreeInfo) => void; onPushNewBranch: (worktree: WorktreeInfo) => void;
/** Pull from a specific remote, bypassing the remote selection dialog */
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Push to a specific remote, bypassing the remote selection dialog */
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void; onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void; onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void; onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
@@ -94,6 +104,7 @@ interface WorktreeActionsDropdownProps {
onCommit: (worktree: WorktreeInfo) => void; onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void; onResolveConflicts: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void; onDeleteWorktree: (worktree: WorktreeInfo) => void;
onStartDevServer: (worktree: WorktreeInfo) => void; onStartDevServer: (worktree: WorktreeInfo) => void;
@@ -120,6 +131,12 @@ interface WorktreeActionsDropdownProps {
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */ /** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
onContinueOperation?: (worktree: WorktreeInfo) => void; onContinueOperation?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean; hasInitScript: boolean;
/** Terminal quick scripts configured for the project */
terminalScripts?: TerminalScript[];
/** Callback to run a terminal quick script in a new terminal session */
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
/** Callback to open the script editor UI */
onEditScripts?: () => void;
} }
export function WorktreeActionsDropdown({ export function WorktreeActionsDropdown({
@@ -141,10 +158,14 @@ export function WorktreeActionsDropdown({
isStartingTests = false, isStartingTests = false,
isTestRunning = false, isTestRunning = false,
testSessionInfo, testSessionInfo,
remotes,
trackingRemote,
onOpenChange, onOpenChange,
onPull, onPull,
onPush, onPush,
onPushNewBranch, onPushNewBranch,
onPullWithRemote,
onPushWithRemote,
onOpenInEditor, onOpenInEditor,
onOpenInIntegratedTerminal, onOpenInIntegratedTerminal,
onOpenInExternalTerminal, onOpenInExternalTerminal,
@@ -154,6 +175,7 @@ export function WorktreeActionsDropdown({
onCommit, onCommit,
onCreatePR, onCreatePR,
onAddressPRComments, onAddressPRComments,
onAutoAddressPRComments,
onResolveConflicts, onResolveConflicts,
onDeleteWorktree, onDeleteWorktree,
onStartDevServer, onStartDevServer,
@@ -172,6 +194,9 @@ export function WorktreeActionsDropdown({
onAbortOperation, onAbortOperation,
onContinueOperation, onContinueOperation,
hasInitScript, hasInitScript,
terminalScripts,
onRunTerminalScript,
onEditScripts,
}: WorktreeActionsDropdownProps) { }: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu // Get available editors for the "Open In" submenu
const { editors } = useAvailableEditors(); const { editors } = useAvailableEditors();
@@ -217,9 +242,11 @@ export function WorktreeActionsDropdown({
: null; : null;
// Determine if the changes/PR section has any visible items // Determine if the changes/PR section has any visible items
const showCreatePR = (!worktree.isMain || worktree.hasChanges) && !hasPR; // Show Create PR when no existing PR is linked
const showCreatePR = !hasPR;
const showPRInfo = hasPR && !!worktree.pr; const showPRInfo = hasPR && !!worktree.pr;
const hasChangesSectionContent = worktree.hasChanges || showCreatePR || showPRInfo; const hasChangesSectionContent =
worktree.hasChanges || showCreatePR || showPRInfo || !!(onStashChanges || onViewStashes);
// Determine if the destructive/bottom section has any visible items // Determine if the destructive/bottom section has any visible items
const hasDestructiveSectionContent = worktree.hasChanges || !worktree.isMain; const hasDestructiveSectionContent = worktree.hasChanges || !worktree.isMain;
@@ -317,20 +344,43 @@ export function WorktreeActionsDropdown({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</> </>
)} )}
{/* Auto Mode toggle */}
{onToggleAutoMode && (
<>
{isAutoModeRunning ? (
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
<span className="flex items-center mr-2">
<Zap className="w-3.5 h-3.5 text-yellow-500" />
<span className="ml-1.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
Stop Auto Mode
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
<Zap className="w-3.5 h-3.5 mr-2" />
Start Auto Mode
</DropdownMenuItem>
)}
</>
)}
{isDevServerRunning ? ( {isDevServerRunning ? (
<> <>
<DropdownMenuLabel className="text-xs flex items-center gap-2"> <DropdownMenuLabel className="text-xs flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" /> <span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
Dev Server Running (:{devServerInfo?.port}) {devServerInfo?.urlDetected === false
? 'Dev Server Starting...'
: `Dev Server Running (:${devServerInfo?.port})`}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuItem {devServerInfo?.urlDetected !== false && (
onClick={() => onOpenDevServerUrl(worktree)} <DropdownMenuItem
className="text-xs" onClick={() => onOpenDevServerUrl(worktree)}
aria-label={`Open dev server on port ${devServerInfo?.port} in browser`} className="text-xs"
> aria-label={`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 <Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
</DropdownMenuItem> Open in Browser
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs"> <DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
<ScrollText className="w-3.5 h-3.5 mr-2" /> <ScrollText className="w-3.5 h-3.5 mr-2" />
View Logs View Logs
@@ -416,188 +466,6 @@ export function WorktreeActionsDropdown({
)} )}
</> </>
)} )}
{/* Auto Mode toggle */}
{onToggleAutoMode && (
<>
{isAutoModeRunning ? (
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
<span className="flex items-center mr-2">
<Zap className="w-3.5 h-3.5 text-yellow-500" />
<span className="ml-1.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
Stop Auto Mode
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
<Zap className="w-3.5 h-3.5 mr-2" />
Start Auto Mode
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onPull(worktree)}
disabled={isPulling || !isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
{isPulling ? 'Pulling...' : 'Pull'}
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
{isGitOpsAvailable && behindCount > 0 && (
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
{behindCount} behind
</span>
)}
</DropdownMenuItem>
</TooltipWrapper>
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => {
if (!isGitOpsAvailable) return;
if (!hasRemoteBranch) {
onPushNewBranch(worktree);
} else {
onPush(worktree);
}
}}
disabled={isPushing || (hasRemoteBranch && aheadCount === 0) || !isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
{isPushing ? 'Pushing...' : 'Push'}
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
{isGitOpsAvailable && !hasRemoteBranch && (
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-amber-500/20 text-amber-600 dark:text-amber-400 px-1.5 py-0.5 rounded">
<CloudOff className="w-2.5 h-2.5" />
local only
</span>
)}
{isGitOpsAvailable && hasRemoteBranch && aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead
</span>
)}
</DropdownMenuItem>
</TooltipWrapper>
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onResolveConflicts(worktree)}
disabled={!isGitOpsAvailable}
className={cn(
'text-xs text-purple-500 focus:text-purple-600',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
>
<GitMerge className="w-3.5 h-3.5 mr-2" />
Merge & Rebase
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
disabled={!isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<History className="w-3.5 h-3.5 mr-2" />
View Commits
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
{/* Cherry-pick commits from another branch */}
{onCherryPick && (
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onCherryPick(worktree)}
disabled={!isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Cherry className="w-3.5 h-3.5 mr-2" />
Cherry Pick
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
)}
{/* Stash operations - combined submenu or simple item */}
{(onStashChanges || onViewStashes) && (
<TooltipWrapper showTooltip={!isGitOpsAvailable} tooltipContent={gitOpsDisabledReason}>
{onViewStashes && worktree.hasChanges && onStashChanges ? (
// Both "Stash Changes" (primary) and "View Stashes" (secondary) are available - show split submenu
<DropdownMenuSub>
<div className="flex items-center">
{/* Main clickable area - stash changes (primary action) */}
<DropdownMenuItem
onClick={() => {
if (!isGitOpsAvailable) return;
onStashChanges(worktree);
}}
disabled={!isGitOpsAvailable}
className={cn(
'text-xs flex-1 pr-0 rounded-r-none',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
>
<Archive className="w-3.5 h-3.5 mr-2" />
Stash Changes
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
{/* Chevron trigger for submenu with stash options */}
<DropdownMenuSubTrigger
className={cn(
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
disabled={!isGitOpsAvailable}
/>
</div>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
<Eye className="w-3.5 h-3.5 mr-2" />
View Stashes
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
// Only one action is meaningful - render a simple menu item without submenu
<DropdownMenuItem
onClick={() => {
if (!isGitOpsAvailable) return;
if (worktree.hasChanges && onStashChanges) {
onStashChanges(worktree);
} else if (onViewStashes) {
onViewStashes(worktree);
}
}}
disabled={!isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Archive className="w-3.5 h-3.5 mr-2" />
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
)}
</TooltipWrapper>
)}
<DropdownMenuSeparator />
{/* Open in editor - split button: click main area for default, chevron for other options */} {/* Open in editor - split button: click main area for default, chevron for other options */}
{effectiveDefaultEditor && ( {effectiveDefaultEditor && (
<DropdownMenuSub> <DropdownMenuSub>
@@ -717,19 +585,414 @@ export function WorktreeActionsDropdown({
})} })}
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
{!worktree.isMain && hasInitScript && ( {/* Scripts submenu - consolidates init script and terminal quick scripts */}
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs"> <DropdownMenuSub>
<RefreshCw className="w-3.5 h-3.5 mr-2" /> <DropdownMenuSubTrigger className="text-xs">
Re-run Init Script <ScrollText className="w-3.5 h-3.5 mr-2" />
Scripts
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52">
{/* Re-run Init Script - always shown for non-main worktrees, disabled when no init script configured */}
{!worktree.isMain && (
<>
<DropdownMenuItem
onClick={() => onRunInitScript(worktree)}
className="text-xs"
disabled={!hasInitScript}
>
<RefreshCw className="w-3.5 h-3.5 mr-2" />
Re-run Init Script
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{/* Terminal quick scripts */}
{terminalScripts && terminalScripts.length > 0 ? (
terminalScripts.map((script) => (
<DropdownMenuItem
key={script.id}
onClick={() => onRunTerminalScript?.(worktree, script.command)}
className="text-xs"
>
<Play className="w-3.5 h-3.5 mr-2 shrink-0" />
<span className="truncate">{script.name}</span>
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled className="text-xs text-muted-foreground">
No scripts configured
</DropdownMenuItem>
)}
{/* Divider before Edit Commands & Scripts */}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onEditScripts?.()} className="text-xs">
<Settings2 className="w-3.5 h-3.5 mr-2" />
Edit Commands & Scripts
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
{remotes && remotes.length > 1 && onPullWithRemote ? (
// Multiple remotes - show split button: click main area to pull (default behavior),
// chevron opens submenu showing individual remotes to pull from
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onPull(worktree)}
disabled={isPulling || !isGitOpsAvailable}
className={cn(
'text-xs flex-1 pr-0 rounded-r-none',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
>
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
{isPulling ? 'Pulling...' : 'Pull'}
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
{isGitOpsAvailable && behindCount > 0 && (
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
{behindCount} behind
</span>
)}
</DropdownMenuItem>
<DropdownMenuSubTrigger
className={cn(
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
(!isGitOpsAvailable || isPulling) && 'opacity-50 cursor-not-allowed'
)}
disabled={!isGitOpsAvailable || isPulling}
/>
</div>
<DropdownMenuSubContent>
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
Pull from remote
</DropdownMenuLabel>
<DropdownMenuSeparator />
{remotes.map((remote) => (
<DropdownMenuItem
key={remote.name}
onClick={() => isGitOpsAvailable && onPullWithRemote(worktree, remote.name)}
disabled={isPulling || !isGitOpsAvailable}
className="text-xs"
>
<Download className="w-3.5 h-3.5 mr-2" />
{remote.name}
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate">
{remote.url}
</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
// Single remote or no remotes - show simple menu item
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onPull(worktree)}
disabled={isPulling || !isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
{isPulling ? 'Pulling...' : 'Pull'}
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
{isGitOpsAvailable && behindCount > 0 && (
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
{behindCount} behind
</span>
)}
</DropdownMenuItem>
)}
</TooltipWrapper>
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
{remotes && remotes.length > 1 && onPushWithRemote ? (
// Multiple remotes - show split button: click main area for default push behavior,
// chevron opens submenu showing individual remotes to push to
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem
onClick={() => {
if (!isGitOpsAvailable) return;
if (!hasRemoteBranch) {
onPushNewBranch(worktree);
} else {
onPush(worktree);
}
}}
disabled={
isPushing || (hasRemoteBranch && aheadCount === 0) || !isGitOpsAvailable
}
className={cn(
'text-xs flex-1 pr-0 rounded-r-none',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
>
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
{isPushing ? 'Pushing...' : 'Push'}
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
{isGitOpsAvailable && !hasRemoteBranch && (
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-amber-500/20 text-amber-600 dark:text-amber-400 px-1.5 py-0.5 rounded">
<CloudOff className="w-2.5 h-2.5" />
local only
</span>
)}
{isGitOpsAvailable && hasRemoteBranch && aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead
</span>
)}
{isGitOpsAvailable && hasRemoteBranch && trackingRemote && (
<span
className={cn(
'text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded',
aheadCount > 0 ? 'ml-1' : 'ml-auto'
)}
>
{trackingRemote}
</span>
)}
</DropdownMenuItem>
<DropdownMenuSubTrigger
className={cn(
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
(!isGitOpsAvailable || isPushing) && 'opacity-50 cursor-not-allowed'
)}
disabled={!isGitOpsAvailable || isPushing}
/>
</div>
<DropdownMenuSubContent>
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
Push to remote
</DropdownMenuLabel>
<DropdownMenuSeparator />
{remotes.map((remote) => (
<DropdownMenuItem
key={remote.name}
onClick={() => isGitOpsAvailable && onPushWithRemote(worktree, remote.name)}
disabled={isPushing || !isGitOpsAvailable}
className="text-xs"
>
<Upload className="w-3.5 h-3.5 mr-2" />
{remote.name}
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate">
{remote.url}
</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
// Single remote or no remotes - show simple menu item
<DropdownMenuItem
onClick={() => {
if (!isGitOpsAvailable) return;
if (!hasRemoteBranch) {
onPushNewBranch(worktree);
} else {
onPush(worktree);
}
}}
disabled={isPushing || (hasRemoteBranch && aheadCount === 0) || !isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
{isPushing ? 'Pushing...' : 'Push'}
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
{isGitOpsAvailable && !hasRemoteBranch && (
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-amber-500/20 text-amber-600 dark:text-amber-400 px-1.5 py-0.5 rounded">
<CloudOff className="w-2.5 h-2.5" />
local only
</span>
)}
{isGitOpsAvailable && hasRemoteBranch && aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead
</span>
)}
{isGitOpsAvailable && hasRemoteBranch && trackingRemote && (
<span
className={cn(
'text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded',
aheadCount > 0 ? 'ml-1' : 'ml-auto'
)}
>
{trackingRemote}
</span>
)}
</DropdownMenuItem>
)}
</TooltipWrapper>
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onResolveConflicts(worktree)}
disabled={!isGitOpsAvailable}
className={cn(
'text-xs text-purple-500 focus:text-purple-600',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
>
<GitMerge className="w-3.5 h-3.5 mr-2" />
Merge & Rebase
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem> </DropdownMenuItem>
</TooltipWrapper>
{!worktree.isMain && (
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onMerge(worktree)}
disabled={!isGitOpsAvailable}
className={cn(
'text-xs text-green-600 focus:text-green-700',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
>
<GitMerge className="w-3.5 h-3.5 mr-2" />
Integrate Branch
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
)}
{/* View Commits - split button when Cherry Pick is available:
click main area to view commits directly, chevron opens sub-menu with Cherry Pick */}
{onCherryPick ? (
<DropdownMenuSub>
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
<div className="flex items-center">
{/* Main clickable area - opens commit history directly */}
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
disabled={!isGitOpsAvailable}
className={cn(
'text-xs flex-1 pr-0 rounded-r-none',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
>
<History className="w-3.5 h-3.5 mr-2" />
View Commits
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
{/* Chevron trigger for sub-menu containing Cherry Pick */}
<DropdownMenuSubTrigger
disabled={!isGitOpsAvailable}
className={cn(
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
/>
</div>
</TooltipWrapper>
<DropdownMenuSubContent>
{/* Cherry-pick commits from another branch */}
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onCherryPick(worktree)}
disabled={!isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Cherry className="w-3.5 h-3.5 mr-2" />
Cherry Pick
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
disabled={!isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<History className="w-3.5 h-3.5 mr-2" />
View Commits
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
)} )}
{(hasChangesSectionContent || hasDestructiveSectionContent) && <DropdownMenuSeparator />} {(hasChangesSectionContent || hasDestructiveSectionContent) && <DropdownMenuSeparator />}
{worktree.hasChanges && ( {/* View Changes split button - main action views changes directly, chevron reveals stash options.
<DropdownMenuItem onClick={() => onViewChanges(worktree)} className="text-xs"> Only render when at least one action is meaningful:
<Eye className="w-3.5 h-3.5 mr-2" /> - worktree.hasChanges: View Changes action is available
View Changes - (worktree.hasChanges && onStashChanges): Create Stash action is possible
</DropdownMenuItem> - onViewStashes: viewing existing stashes is possible */}
{(worktree.hasChanges || onViewStashes) && (
<DropdownMenuSub>
<div className="flex items-center">
{/* Main clickable area - view changes (primary action) */}
{worktree.hasChanges ? (
<DropdownMenuItem
onClick={() => onViewChanges(worktree)}
className="text-xs flex-1 pr-0 rounded-r-none"
>
<Eye className="w-3.5 h-3.5 mr-2" />
View Changes
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => onViewStashes!(worktree)}
className="text-xs flex-1 pr-0 rounded-r-none"
>
<Eye className="w-3.5 h-3.5 mr-2" />
View Stashes
</DropdownMenuItem>
)}
{/* Chevron trigger for submenu with stash options */}
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
</div>
<DropdownMenuSubContent>
{worktree.hasChanges && onStashChanges && (
<TooltipWrapper
showTooltip={!isGitOpsAvailable}
tooltipContent={gitOpsDisabledReason}
>
<DropdownMenuItem
onClick={() => {
if (!isGitOpsAvailable) return;
onStashChanges(worktree);
}}
disabled={!isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Archive className="w-3.5 h-3.5 mr-2" />
Create Stash
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
)}
{onViewStashes && (
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
<Eye className="w-3.5 h-3.5 mr-2" />
View Stashes
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)} )}
{worktree.hasChanges && ( {worktree.hasChanges && (
<TooltipWrapper <TooltipWrapper
@@ -749,7 +1012,7 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem> </DropdownMenuItem>
</TooltipWrapper> </TooltipWrapper>
)} )}
{/* Show PR option for non-primary worktrees, or primary worktree with changes */} {/* Show PR option when there is no existing PR (showCreatePR === !hasPR) */}
{showCreatePR && ( {showCreatePR && (
<TooltipWrapper <TooltipWrapper
showTooltip={!!gitOpsDisabledReason} showTooltip={!!gitOpsDisabledReason}
@@ -768,43 +1031,69 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem> </DropdownMenuItem>
</TooltipWrapper> </TooltipWrapper>
)} )}
{/* Show PR info and Address Comments button if PR exists */} {/* Show PR info with Address Comments in sub-menu if PR exists */}
{showPRInfo && worktree.pr && ( {showPRInfo && worktree.pr && (
<> <DropdownMenuSub>
<DropdownMenuItem <div className="flex items-center">
onClick={() => { {/* Main clickable area - opens PR in browser */}
window.open(worktree.pr!.url, '_blank', 'noopener,noreferrer'); <DropdownMenuItem
}} onClick={() => {
className="text-xs" window.open(worktree.pr!.url, '_blank', 'noopener,noreferrer');
> }}
<GitPullRequest className="w-3 h-3 mr-2" /> className="text-xs flex-1 pr-0 rounded-r-none"
PR #{worktree.pr.number} >
<span className="ml-auto text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded uppercase"> <GitPullRequest className="w-3 h-3 mr-2" />
{worktree.pr.state} PR #{worktree.pr.number}
</span> <span className="ml-auto mr-1 text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded uppercase">
</DropdownMenuItem> {worktree.pr.state}
<DropdownMenuItem </span>
onClick={() => { </DropdownMenuItem>
// Convert stored PR info to the full PRInfo format for the handler {/* Chevron trigger for submenu with PR actions */}
// The handler will fetch full comments from GitHub <DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
const prInfo: PRInfo = { </div>
number: worktree.pr!.number, <DropdownMenuSubContent>
title: worktree.pr!.title, <DropdownMenuItem
url: worktree.pr!.url, onClick={() => {
state: worktree.pr!.state, // Convert stored PR info to the full PRInfo format for the handler
author: '', // Will be fetched // The handler will fetch full comments from GitHub
body: '', // Will be fetched const prInfo: PRInfo = {
comments: [], number: worktree.pr!.number,
reviewComments: [], title: worktree.pr!.title,
}; url: worktree.pr!.url,
onAddressPRComments(worktree, prInfo); state: worktree.pr!.state,
}} author: '', // Will be fetched
className="text-xs text-blue-500 focus:text-blue-600" body: '', // Will be fetched
> comments: [],
<MessageSquare className="w-3.5 h-3.5 mr-2" /> reviewComments: [],
Address PR Comments };
</DropdownMenuItem> onAddressPRComments(worktree, prInfo);
</> }}
className="text-xs text-blue-500 focus:text-blue-600"
>
<MessageSquare className="w-3.5 h-3.5 mr-2" />
Manage PR Comments
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
const prInfo: PRInfo = {
number: worktree.pr!.number,
title: worktree.pr!.title,
url: worktree.pr!.url,
state: worktree.pr!.state,
author: '',
body: '',
comments: [],
reviewComments: [],
};
onAutoAddressPRComments(worktree, prInfo);
}}
className="text-xs text-blue-500 focus:text-blue-600"
>
<Zap className="w-3.5 h-3.5 mr-2" />
Address PR Comments
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)} )}
{hasChangesSectionContent && hasDestructiveSectionContent && <DropdownMenuSeparator />} {hasChangesSectionContent && hasDestructiveSectionContent && <DropdownMenuSeparator />}
{worktree.hasChanges && ( {worktree.hasChanges && (
@@ -829,35 +1118,13 @@ export function WorktreeActionsDropdown({
</TooltipWrapper> </TooltipWrapper>
)} )}
{!worktree.isMain && ( {!worktree.isMain && (
<> <DropdownMenuItem
<TooltipWrapper onClick={() => onDeleteWorktree(worktree)}
showTooltip={!!gitOpsDisabledReason} className="text-xs text-destructive focus:text-destructive"
tooltipContent={gitOpsDisabledReason} >
> <Trash2 className="w-3.5 h-3.5 mr-2" />
<DropdownMenuItem Delete Worktree
onClick={() => isGitOpsAvailable && onMerge(worktree)} </DropdownMenuItem>
disabled={!isGitOpsAvailable}
className={cn(
'text-xs text-green-600 focus:text-green-700',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
>
<GitMerge className="w-3.5 h-3.5 mr-2" />
Merge Branch
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDeleteWorktree(worktree)}
className="text-xs text-destructive focus:text-destructive"
>
<Trash2 className="w-3.5 h-3.5 mr-2" />
Delete Worktree
</DropdownMenuItem>
</>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -144,8 +144,8 @@ export function WorktreeDropdownItem({
</span> </span>
)} )}
{/* Dev server indicator */} {/* Dev server indicator - only shown when port is confirmed detected */}
{devServerRunning && ( {devServerRunning && devServerInfo?.urlDetected !== false && (
<span <span
className="inline-flex items-center justify-center h-4 w-4 text-green-500" className="inline-flex items-center justify-center h-4 w-4 text-green-500"
title={`Dev server running on port ${devServerInfo?.port}`} title={`Dev server running on port ${devServerInfo?.port}`}

View File

@@ -82,6 +82,10 @@ export interface WorktreeDropdownProps {
aheadCount: number; aheadCount: number;
behindCount: number; behindCount: number;
hasRemoteBranch: boolean; hasRemoteBranch: boolean;
/** The name of the remote that the current branch is tracking (e.g. "origin"), if any */
trackingRemote?: string;
/** Per-worktree tracking remote lookup */
getTrackingRemote?: (worktreePath: string) => string | undefined;
gitRepoStatus: GitRepoStatus; gitRepoStatus: GitRepoStatus;
hasTestCommand: boolean; hasTestCommand: boolean;
isStartingTests: boolean; isStartingTests: boolean;
@@ -99,6 +103,7 @@ export interface WorktreeDropdownProps {
onCommit: (worktree: WorktreeInfo) => void; onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void; onResolveConflicts: (worktree: WorktreeInfo) => void;
onMerge: (worktree: WorktreeInfo) => void; onMerge: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void; onDeleteWorktree: (worktree: WorktreeInfo) => void;
@@ -121,6 +126,18 @@ export interface WorktreeDropdownProps {
onAbortOperation?: (worktree: WorktreeInfo) => void; onAbortOperation?: (worktree: WorktreeInfo) => void;
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */ /** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
onContinueOperation?: (worktree: WorktreeInfo) => void; onContinueOperation?: (worktree: WorktreeInfo) => void;
/** Remotes cache: maps worktree path to list of remotes */
remotesCache?: Record<string, Array<{ name: string; url: string }>>;
/** Pull from a specific remote, bypassing the remote selection dialog */
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Push to a specific remote, bypassing the remote selection dialog */
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Terminal quick scripts configured for the project */
terminalScripts?: import('@/components/views/project-settings-view/terminal-scripts-constants').TerminalScript[];
/** Callback to run a terminal quick script in a new terminal session */
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
/** Callback to open the script editor UI */
onEditScripts?: () => void;
} }
/** /**
@@ -170,6 +187,8 @@ export function WorktreeDropdown({
aheadCount, aheadCount,
behindCount, behindCount,
hasRemoteBranch, hasRemoteBranch,
trackingRemote,
getTrackingRemote,
gitRepoStatus, gitRepoStatus,
hasTestCommand, hasTestCommand,
isStartingTests, isStartingTests,
@@ -187,6 +206,7 @@ export function WorktreeDropdown({
onCommit, onCommit,
onCreatePR, onCreatePR,
onAddressPRComments, onAddressPRComments,
onAutoAddressPRComments,
onResolveConflicts, onResolveConflicts,
onMerge, onMerge,
onDeleteWorktree, onDeleteWorktree,
@@ -204,6 +224,12 @@ export function WorktreeDropdown({
onCherryPick, onCherryPick,
onAbortOperation, onAbortOperation,
onContinueOperation, onContinueOperation,
remotesCache,
onPullWithRemote,
onPushWithRemote,
terminalScripts,
onRunTerminalScript,
onEditScripts,
}: WorktreeDropdownProps) { }: WorktreeDropdownProps) {
// Find the currently selected worktree to display in the trigger // Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w)); const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
@@ -289,8 +315,8 @@ export function WorktreeDropdown({
</span> </span>
)} )}
{/* Dev server indicator */} {/* Dev server indicator - only shown when port is confirmed detected */}
{selectedStatus.devServerRunning && ( {selectedStatus.devServerRunning && selectedStatus.devServerInfo?.urlDetected !== false && (
<span <span
className="inline-flex items-center justify-center h-4 w-4 text-green-500 shrink-0" className="inline-flex items-center justify-center h-4 w-4 text-green-500 shrink-0"
title={`Dev server running on port ${selectedStatus.devServerInfo?.port}`} title={`Dev server running on port ${selectedStatus.devServerInfo?.port}`}
@@ -470,6 +496,9 @@ export function WorktreeDropdown({
aheadCount={aheadCount} aheadCount={aheadCount}
behindCount={behindCount} behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch} hasRemoteBranch={hasRemoteBranch}
trackingRemote={
getTrackingRemote ? getTrackingRemote(selectedWorktree.path) : trackingRemote
}
isPulling={isPulling} isPulling={isPulling}
isPushing={isPushing} isPushing={isPushing}
isStartingDevServer={isStartingDevServer} isStartingDevServer={isStartingDevServer}
@@ -482,10 +511,13 @@ export function WorktreeDropdown({
isStartingTests={isStartingTests} isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(selectedWorktree)} isTestRunning={isTestRunningForWorktree(selectedWorktree)}
testSessionInfo={getTestSessionInfo(selectedWorktree)} testSessionInfo={getTestSessionInfo(selectedWorktree)}
remotes={remotesCache?.[selectedWorktree.path]}
onOpenChange={onActionsDropdownOpenChange(selectedWorktree)} onOpenChange={onActionsDropdownOpenChange(selectedWorktree)}
onPull={onPull} onPull={onPull}
onPush={onPush} onPush={onPush}
onPushNewBranch={onPushNewBranch} onPushNewBranch={onPushNewBranch}
onPullWithRemote={onPullWithRemote}
onPushWithRemote={onPushWithRemote}
onOpenInEditor={onOpenInEditor} onOpenInEditor={onOpenInEditor}
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal} onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal} onOpenInExternalTerminal={onOpenInExternalTerminal}
@@ -495,6 +527,7 @@ export function WorktreeDropdown({
onCommit={onCommit} onCommit={onCommit}
onCreatePR={onCreatePR} onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments} onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts} onResolveConflicts={onResolveConflicts}
onMerge={onMerge} onMerge={onMerge}
onDeleteWorktree={onDeleteWorktree} onDeleteWorktree={onDeleteWorktree}
@@ -513,6 +546,9 @@ export function WorktreeDropdown({
onAbortOperation={onAbortOperation} onAbortOperation={onAbortOperation}
onContinueOperation={onContinueOperation} onContinueOperation={onContinueOperation}
hasInitScript={hasInitScript} hasInitScript={hasInitScript}
terminalScripts={terminalScripts}
onRunTerminalScript={onRunTerminalScript}
onEditScripts={onEditScripts}
/> />
)} )}
</div> </div>

View File

@@ -38,6 +38,8 @@ interface WorktreeTabProps {
aheadCount: number; aheadCount: number;
behindCount: number; behindCount: number;
hasRemoteBranch: boolean; hasRemoteBranch: boolean;
/** The name of the remote that the current branch is tracking (e.g. "origin"), if any */
trackingRemote?: string;
gitRepoStatus: GitRepoStatus; gitRepoStatus: GitRepoStatus;
/** Whether auto mode is running for this worktree */ /** Whether auto mode is running for this worktree */
isAutoModeRunning?: boolean; isAutoModeRunning?: boolean;
@@ -65,6 +67,7 @@ interface WorktreeTabProps {
onCommit: (worktree: WorktreeInfo) => void; onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void; onResolveConflicts: (worktree: WorktreeInfo) => void;
onMerge: (worktree: WorktreeInfo) => void; onMerge: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void; onDeleteWorktree: (worktree: WorktreeInfo) => void;
@@ -93,6 +96,18 @@ interface WorktreeTabProps {
hasInitScript: boolean; hasInitScript: boolean;
/** Whether a test command is configured in project settings */ /** Whether a test command is configured in project settings */
hasTestCommand?: boolean; hasTestCommand?: boolean;
/** List of available remotes for this worktree (used to show remote submenu) */
remotes?: Array<{ name: string; url: string }>;
/** Pull from a specific remote, bypassing the remote selection dialog */
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Push to a specific remote, bypassing the remote selection dialog */
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Terminal quick scripts configured for the project */
terminalScripts?: import('@/components/views/project-settings-view/terminal-scripts-constants').TerminalScript[];
/** Callback to run a terminal quick script in a new terminal session */
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
/** Callback to open the script editor UI */
onEditScripts?: () => void;
} }
export function WorktreeTab({ export function WorktreeTab({
@@ -116,6 +131,7 @@ export function WorktreeTab({
aheadCount, aheadCount,
behindCount, behindCount,
hasRemoteBranch, hasRemoteBranch,
trackingRemote,
gitRepoStatus, gitRepoStatus,
isAutoModeRunning = false, isAutoModeRunning = false,
isStartingTests = false, isStartingTests = false,
@@ -139,6 +155,7 @@ export function WorktreeTab({
onCommit, onCommit,
onCreatePR, onCreatePR,
onAddressPRComments, onAddressPRComments,
onAutoAddressPRComments,
onResolveConflicts, onResolveConflicts,
onMerge, onMerge,
onDeleteWorktree, onDeleteWorktree,
@@ -158,6 +175,12 @@ export function WorktreeTab({
onContinueOperation, onContinueOperation,
hasInitScript, hasInitScript,
hasTestCommand = false, hasTestCommand = false,
remotes,
onPullWithRemote,
onPushWithRemote,
terminalScripts,
onRunTerminalScript,
onEditScripts,
}: WorktreeTabProps) { }: WorktreeTabProps) {
// Make the worktree tab a drop target for feature cards // Make the worktree tab a drop target for feature cards
const { setNodeRef, isOver } = useDroppable({ const { setNodeRef, isOver } = useDroppable({
@@ -428,7 +451,7 @@ export function WorktreeTab({
</Button> </Button>
)} )}
{isDevServerRunning && ( {isDevServerRunning && devServerInfo?.urlDetected !== false && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@@ -476,6 +499,7 @@ export function WorktreeTab({
aheadCount={aheadCount} aheadCount={aheadCount}
behindCount={behindCount} behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch} hasRemoteBranch={hasRemoteBranch}
trackingRemote={trackingRemote}
isPulling={isPulling} isPulling={isPulling}
isPushing={isPushing} isPushing={isPushing}
isStartingDevServer={isStartingDevServer} isStartingDevServer={isStartingDevServer}
@@ -488,10 +512,13 @@ export function WorktreeTab({
isStartingTests={isStartingTests} isStartingTests={isStartingTests}
isTestRunning={isTestRunning} isTestRunning={isTestRunning}
testSessionInfo={testSessionInfo} testSessionInfo={testSessionInfo}
remotes={remotes}
onOpenChange={onActionsDropdownOpenChange} onOpenChange={onActionsDropdownOpenChange}
onPull={onPull} onPull={onPull}
onPush={onPush} onPush={onPush}
onPushNewBranch={onPushNewBranch} onPushNewBranch={onPushNewBranch}
onPullWithRemote={onPullWithRemote}
onPushWithRemote={onPushWithRemote}
onOpenInEditor={onOpenInEditor} onOpenInEditor={onOpenInEditor}
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal} onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal} onOpenInExternalTerminal={onOpenInExternalTerminal}
@@ -501,6 +528,7 @@ export function WorktreeTab({
onCommit={onCommit} onCommit={onCommit}
onCreatePR={onCreatePR} onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments} onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts} onResolveConflicts={onResolveConflicts}
onMerge={onMerge} onMerge={onMerge}
onDeleteWorktree={onDeleteWorktree} onDeleteWorktree={onDeleteWorktree}
@@ -519,6 +547,9 @@ export function WorktreeTab({
onAbortOperation={onAbortOperation} onAbortOperation={onAbortOperation}
onContinueOperation={onContinueOperation} onContinueOperation={onContinueOperation}
hasInitScript={hasInitScript} hasInitScript={hasInitScript}
terminalScripts={terminalScripts}
onRunTerminalScript={onRunTerminalScript}
onEditScripts={onEditScripts}
/> />
</div> </div>
); );

View File

@@ -1,7 +1,32 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
import { useWorktreeBranches } from '@/hooks/queries'; import { useWorktreeBranches } from '@/hooks/queries';
import type { GitRepoStatus } from '../types'; import type { GitRepoStatus } from '../types';
/** Explicit return type for the useBranches hook */
export interface UseBranchesReturn {
branches: Array<{ name: string; isCurrent: boolean; isRemote: boolean }>;
filteredBranches: Array<{ name: string; isCurrent: boolean; isRemote: boolean }>;
aheadCount: number;
behindCount: number;
hasRemoteBranch: boolean;
/**
* @deprecated Use {@link getTrackingRemote}(worktreePath) instead — this value
* only reflects the last-queried worktree and is unreliable when multiple panels
* share the hook.
*/
trackingRemote: string | undefined;
/** Per-worktree tracking remote lookup — avoids stale values when multiple panels share the hook */
getTrackingRemote: (worktreePath: string) => string | undefined;
isLoadingBranches: boolean;
branchFilter: string;
setBranchFilter: (filter: string) => void;
resetBranchFilter: () => void;
fetchBranches: (worktreePath: string) => void;
/** Prune cached tracking-remote entries for worktree paths that no longer exist */
pruneStaleEntries: (activePaths: Set<string>) => void;
gitRepoStatus: GitRepoStatus;
}
/** /**
* Hook for managing branch data with React Query * Hook for managing branch data with React Query
* *
@@ -9,7 +34,7 @@ import type { GitRepoStatus } from '../types';
* the current interface for backward compatibility. Tracks which * the current interface for backward compatibility. Tracks which
* worktree path is currently being viewed and fetches branches on demand. * worktree path is currently being viewed and fetches branches on demand.
*/ */
export function useBranches() { export function useBranches(): UseBranchesReturn {
const [currentWorktreePath, setCurrentWorktreePath] = useState<string | undefined>(); const [currentWorktreePath, setCurrentWorktreePath] = useState<string | undefined>();
const [branchFilter, setBranchFilter] = useState(''); const [branchFilter, setBranchFilter] = useState('');
@@ -23,6 +48,31 @@ export function useBranches() {
const aheadCount = branchData?.aheadCount ?? 0; const aheadCount = branchData?.aheadCount ?? 0;
const behindCount = branchData?.behindCount ?? 0; const behindCount = branchData?.behindCount ?? 0;
const hasRemoteBranch = branchData?.hasRemoteBranch ?? false; const hasRemoteBranch = branchData?.hasRemoteBranch ?? false;
const trackingRemote = branchData?.trackingRemote;
// Per-worktree tracking remote cache: keeps results from previous fetchBranches()
// calls so multiple WorktreePanel instances don't all share a single stale value.
const trackingRemoteByPathRef = useRef<Record<string, string | undefined>>({});
// Update cache whenever query data changes for the current path
useEffect(() => {
if (currentWorktreePath && branchData) {
trackingRemoteByPathRef.current[currentWorktreePath] = branchData.trackingRemote;
}
}, [currentWorktreePath, branchData]);
const getTrackingRemote = useCallback(
(worktreePath: string): string | undefined => {
// If asking about the currently active query path, use fresh data
if (worktreePath === currentWorktreePath) {
return trackingRemote;
}
// Otherwise fall back to the cached value from a previous fetch
return trackingRemoteByPathRef.current[worktreePath];
},
[currentWorktreePath, trackingRemote]
);
// Use conservative defaults (false) until data is confirmed // Use conservative defaults (false) until data is confirmed
// This prevents the UI from assuming git capabilities before the query completes // This prevents the UI from assuming git capabilities before the query completes
const gitRepoStatus: GitRepoStatus = { const gitRepoStatus: GitRepoStatus = {
@@ -47,6 +97,16 @@ export function useBranches() {
setBranchFilter(''); setBranchFilter('');
}, []); }, []);
/** Remove cached tracking-remote entries for worktree paths that no longer exist. */
const pruneStaleEntries = useCallback((activePaths: Set<string>) => {
const cache = trackingRemoteByPathRef.current;
for (const key of Object.keys(cache)) {
if (!activePaths.has(key)) {
delete cache[key];
}
}
}, []);
const filteredBranches = branches.filter((b) => const filteredBranches = branches.filter((b) =>
b.name.toLowerCase().includes(branchFilter.toLowerCase()) b.name.toLowerCase().includes(branchFilter.toLowerCase())
); );
@@ -57,11 +117,14 @@ export function useBranches() {
aheadCount, aheadCount,
behindCount, behindCount,
hasRemoteBranch, hasRemoteBranch,
trackingRemote,
getTrackingRemote,
isLoadingBranches, isLoadingBranches,
branchFilter, branchFilter,
setBranchFilter, setBranchFilter,
resetBranchFilter, resetBranchFilter,
fetchBranches, fetchBranches,
pruneStaleEntries,
gitRepoStatus, gitRepoStatus,
}; };
} }

View File

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

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger'; import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { normalizePath } from '@/lib/utils'; import { normalizePath } from '@/lib/utils';
@@ -11,10 +11,32 @@ interface UseDevServersOptions {
projectPath: string; projectPath: string;
} }
/**
* Helper to build the browser-accessible dev server URL by rewriting the hostname
* to match the current window's hostname (supports remote access).
* Returns null if the URL is invalid or uses an unsupported protocol.
*/
function buildDevServerBrowserUrl(serverUrl: string): string | null {
try {
const devServerUrl = new URL(serverUrl);
// Security: Only allow http/https protocols
if (devServerUrl.protocol !== 'http:' && devServerUrl.protocol !== 'https:') {
return null;
}
devServerUrl.hostname = window.location.hostname;
return devServerUrl.toString();
} catch {
return null;
}
}
export function useDevServers({ projectPath }: UseDevServersOptions) { export function useDevServers({ projectPath }: UseDevServersOptions) {
const [isStartingDevServer, setIsStartingDevServer] = useState(false); const [isStartingDevServer, setIsStartingDevServer] = useState(false);
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(new Map()); const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(new Map());
// Track which worktrees have had their url-detected toast shown to prevent re-triggering
const toastShownForRef = useRef<Set<string>>(new Set());
const fetchDevServers = useCallback(async () => { const fetchDevServers = useCallback(async () => {
try { try {
const api = getElectronAPI(); const api = getElectronAPI();
@@ -25,7 +47,16 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
if (result.success && result.result?.servers) { if (result.success && result.result?.servers) {
const serversMap = new Map<string, DevServerInfo>(); const serversMap = new Map<string, DevServerInfo>();
for (const server of result.result.servers) { for (const server of result.result.servers) {
serversMap.set(server.worktreePath, server); const key = normalizePath(server.worktreePath);
serversMap.set(key, {
...server,
urlDetected: server.urlDetected ?? true,
});
// Mark already-detected servers as having shown the toast
// so we don't re-trigger on initial load
if (server.urlDetected !== false) {
toastShownForRef.current.add(key);
}
} }
setRunningDevServers(serversMap); setRunningDevServers(serversMap);
} }
@@ -38,6 +69,86 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
fetchDevServers(); fetchDevServers();
}, [fetchDevServers]); }, [fetchDevServers]);
// Subscribe to all dev server lifecycle events for reactive state updates
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;
// Avoid updating if already detected with same url/port
if (existing.urlDetected && existing.url === url && existing.port === port) 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})`);
// Only show toast on the transition from undetected → detected (not on re-renders/polls)
if (!toastShownForRef.current.has(key)) {
toastShownForRef.current.add(key);
const browserUrl = buildDevServerBrowserUrl(url);
toast.success(`Dev server running on port ${port}`, {
description: browserUrl ? browserUrl : url,
action: browserUrl
? {
label: 'Open in Browser',
onClick: () => {
window.open(browserUrl, '_blank', 'noopener,noreferrer');
},
}
: undefined,
duration: 8000,
});
}
}
} else if (event.type === 'dev-server:stopped') {
// Reactively remove the server from state when it stops
const { worktreePath } = event.payload;
const key = normalizePath(worktreePath);
setRunningDevServers((prev) => {
if (!prev.has(key)) return prev;
const next = new Map(prev);
next.delete(key);
return next;
});
// Clear the toast tracking so a fresh detection will show a new toast
toastShownForRef.current.delete(key);
logger.info(`Dev server stopped for ${worktreePath} (reactive update)`);
} else if (event.type === 'dev-server:started') {
// Reactively add/update the server when it starts
const { worktreePath, port, url } = event.payload;
const key = normalizePath(worktreePath);
// Clear previous toast tracking for this key so a new detection triggers a fresh toast
toastShownForRef.current.delete(key);
setRunningDevServers((prev) => {
const next = new Map(prev);
next.set(key, {
worktreePath,
port,
url,
urlDetected: false,
});
return next;
});
}
});
return unsubscribe;
}, []);
const getWorktreeKey = useCallback( const getWorktreeKey = useCallback(
(worktree: WorktreeInfo) => { (worktree: WorktreeInfo) => {
const path = worktree.isMain ? projectPath : worktree.path; const path = worktree.isMain ? projectPath : worktree.path;
@@ -62,16 +173,20 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
const result = await api.worktree.startDevServer(projectPath, targetPath); const result = await api.worktree.startDevServer(projectPath, targetPath);
if (result.success && result.result) { if (result.success && result.result) {
const key = normalizePath(targetPath);
// Clear toast tracking so the new port detection shows a fresh toast
toastShownForRef.current.delete(key);
setRunningDevServers((prev) => { setRunningDevServers((prev) => {
const next = new Map(prev); const next = new Map(prev);
next.set(normalizePath(targetPath), { next.set(key, {
worktreePath: result.result!.worktreePath, worktreePath: result.result!.worktreePath,
port: result.result!.port, port: result.result!.port,
url: result.result!.url, url: result.result!.url,
urlDetected: false,
}); });
return next; return next;
}); });
toast.success(`Dev server started on port ${result.result.port}`); toast.success('Dev server started, detecting port...');
} else { } else {
toast.error(result.error || 'Failed to start dev server'); toast.error(result.error || 'Failed to start dev server');
} }
@@ -98,11 +213,14 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
const result = await api.worktree.stopDevServer(targetPath); const result = await api.worktree.stopDevServer(targetPath);
if (result.success) { if (result.success) {
const key = normalizePath(targetPath);
setRunningDevServers((prev) => { setRunningDevServers((prev) => {
const next = new Map(prev); const next = new Map(prev);
next.delete(normalizePath(targetPath)); next.delete(key);
return next; return next;
}); });
// Clear toast tracking so future restarts get a fresh toast
toastShownForRef.current.delete(key);
toast.success(result.result?.message || 'Dev server stopped'); toast.success(result.result?.message || 'Dev server stopped');
} else { } else {
toast.error(result.error || 'Failed to stop dev server'); toast.error(result.error || 'Failed to stop dev server');
@@ -126,30 +244,16 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
return; return;
} }
try { const browserUrl = buildDevServerBrowserUrl(serverInfo.url);
// Rewrite URL hostname to match the current browser's hostname. if (!browserUrl) {
// This ensures dev server URLs work when accessing Automaker from logger.error('Invalid dev server URL:', serverInfo.url);
// remote machines (e.g., 192.168.x.x or hostname.local instead of localhost). toast.error('Invalid dev server URL', {
const devServerUrl = new URL(serverInfo.url); description: 'The server returned an unsupported URL protocol.',
// Security: Only allow http/https protocols to prevent potential attacks
// via data:, javascript:, file:, or other dangerous URL schemes
if (devServerUrl.protocol !== 'http:' && devServerUrl.protocol !== 'https:') {
logger.error('Invalid dev server URL protocol:', devServerUrl.protocol);
toast.error('Invalid dev server URL', {
description: 'The server returned an unsupported URL protocol.',
});
return;
}
devServerUrl.hostname = window.location.hostname;
window.open(devServerUrl.toString(), '_blank', 'noopener,noreferrer');
} catch (error) {
logger.error('Failed to parse dev server URL:', error);
toast.error('Failed to open dev server', {
description: 'The server URL could not be processed. Please try again.',
}); });
return;
} }
window.open(browserUrl, '_blank', 'noopener,noreferrer');
}, },
[runningDevServers, getWorktreeKey] [runningDevServers, getWorktreeKey]
); );

View File

@@ -163,6 +163,24 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
[navigate] [navigate]
); );
const handleRunTerminalScript = useCallback(
(worktree: WorktreeInfo, command: string) => {
// Navigate to the terminal view with the worktree path, branch, and command to run
// The terminal view will create a new terminal and automatically execute the command
navigate({
to: '/terminal',
search: {
cwd: worktree.path,
branch: worktree.branch,
mode: 'tab' as const,
nonce: Date.now(),
command,
},
});
},
[navigate]
);
const handleOpenInEditor = useCallback( const handleOpenInEditor = useCallback(
async (worktree: WorktreeInfo, editorCommand?: string) => { async (worktree: WorktreeInfo, editorCommand?: string) => {
openInEditorMutation.mutate({ openInEditorMutation.mutate({
@@ -204,6 +222,7 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
handlePull, handlePull,
handlePush, handlePush,
handleOpenInIntegratedTerminal, handleOpenInIntegratedTerminal,
handleRunTerminalScript,
handleOpenInEditor, handleOpenInEditor,
handleOpenInExternalTerminal, handleOpenInExternalTerminal,
// Stash confirmation state for branch switching // Stash confirmation state for branch switching

View File

@@ -90,8 +90,16 @@ export function useWorktrees({
const handleSelectWorktree = useCallback( const handleSelectWorktree = useCallback(
(worktree: WorktreeInfo) => { (worktree: WorktreeInfo) => {
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch); setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch);
// Invalidate feature queries when switching worktrees to ensure fresh data.
// Without this, feature cards that remount after the worktree switch may have stale
// or missing planSpec/task data, causing todo lists to appear empty until the next
// polling cycle or user interaction triggers a re-render.
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(projectPath),
});
}, },
[projectPath, setCurrentWorktree] [projectPath, setCurrentWorktree, queryClient]
); );
// fetchWorktrees for backward compatibility - now just triggers a refetch // fetchWorktrees for backward compatibility - now just triggers a refetch

View File

@@ -34,6 +34,8 @@ export interface DevServerInfo {
worktreePath: string; worktreePath: string;
port: number; port: number;
url: string; url: string;
/** Whether the actual URL/port has been detected from server output */
urlDetected?: boolean;
} }
export interface TestSessionInfo { export interface TestSessionInfo {
@@ -120,6 +122,7 @@ export interface WorktreePanelProps {
onCreatePR: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void;
onCreateBranch: (worktree: WorktreeInfo) => void; onCreateBranch: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void; onResolveConflicts: (worktree: WorktreeInfo) => void;
onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void; onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
/** Called when branch switch stash reapply results in merge conflicts */ /** Called when branch switch stash reapply results in merge conflicts */

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useCallback, useState } from 'react'; import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw } from 'lucide-react'; import { GitBranch, Plus, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
@@ -9,6 +10,7 @@ import { useIsMobile } from '@/hooks/use-media-query';
import { useWorktreeInitScript, useProjectSettings } from '@/hooks/queries'; import { useWorktreeInitScript, useProjectSettings } from '@/hooks/queries';
import { useTestRunnerEvents } from '@/hooks/use-test-runners'; import { useTestRunnerEvents } from '@/hooks/use-test-runners';
import { useTestRunnersStore } from '@/store/test-runners-store'; import { useTestRunnersStore } from '@/store/test-runners-store';
import { DEFAULT_TERMINAL_SCRIPTS } from '@/components/views/project-settings-view/terminal-scripts-constants';
import type { import type {
TestRunnerStartedEvent, TestRunnerStartedEvent,
TestRunnerOutputEvent, TestRunnerOutputEvent,
@@ -59,6 +61,7 @@ export function WorktreePanel({
onCreatePR, onCreatePR,
onCreateBranch, onCreateBranch,
onAddressPRComments, onAddressPRComments,
onAutoAddressPRComments,
onResolveConflicts, onResolveConflicts,
onCreateMergeConflictResolutionFeature, onCreateMergeConflictResolutionFeature,
onBranchSwitchConflict, onBranchSwitchConflict,
@@ -96,11 +99,14 @@ export function WorktreePanel({
aheadCount, aheadCount,
behindCount, behindCount,
hasRemoteBranch, hasRemoteBranch,
trackingRemote,
getTrackingRemote,
isLoadingBranches, isLoadingBranches,
branchFilter, branchFilter,
setBranchFilter, setBranchFilter,
resetBranchFilter, resetBranchFilter,
fetchBranches, fetchBranches,
pruneStaleEntries,
gitRepoStatus, gitRepoStatus,
} = useBranches(); } = useBranches();
@@ -113,6 +119,7 @@ export function WorktreePanel({
handlePull: _handlePull, handlePull: _handlePull,
handlePush, handlePush,
handleOpenInIntegratedTerminal, handleOpenInIntegratedTerminal,
handleRunTerminalScript,
handleOpenInEditor, handleOpenInEditor,
handleOpenInExternalTerminal, handleOpenInExternalTerminal,
pendingSwitch, pendingSwitch,
@@ -206,6 +213,21 @@ export function WorktreePanel({
const { data: projectSettings } = useProjectSettings(projectPath); const { data: projectSettings } = useProjectSettings(projectPath);
const hasTestCommand = !!projectSettings?.testCommand; const hasTestCommand = !!projectSettings?.testCommand;
// Get terminal quick scripts from project settings (or fall back to defaults)
const terminalScripts = useMemo(() => {
const configured = projectSettings?.terminalScripts;
if (configured && configured.length > 0) {
return configured;
}
return DEFAULT_TERMINAL_SCRIPTS;
}, [projectSettings?.terminalScripts]);
// Navigate to project settings to edit scripts
const navigate = useNavigate();
const handleEditScripts = useCallback(() => {
navigate({ to: '/project-settings', search: { section: 'commands-scripts' } });
}, [navigate]);
// Test runner state management // Test runner state management
// Use the test runners store to get global state for all worktrees // Use the test runners store to get global state for all worktrees
const testRunnersStore = useTestRunnersStore(); const testRunnersStore = useTestRunnersStore();
@@ -410,7 +432,7 @@ export function WorktreePanel({
const [pushToRemoteDialogOpen, setPushToRemoteDialogOpen] = useState(false); const [pushToRemoteDialogOpen, setPushToRemoteDialogOpen] = useState(false);
const [pushToRemoteWorktree, setPushToRemoteWorktree] = useState<WorktreeInfo | null>(null); const [pushToRemoteWorktree, setPushToRemoteWorktree] = useState<WorktreeInfo | null>(null);
// Merge branch dialog state // Integrate branch dialog state
const [mergeDialogOpen, setMergeDialogOpen] = useState(false); const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
const [mergeWorktree, setMergeWorktree] = useState<WorktreeInfo | null>(null); const [mergeWorktree, setMergeWorktree] = useState<WorktreeInfo | null>(null);
@@ -434,6 +456,11 @@ export function WorktreePanel({
const [pullDialogWorktree, setPullDialogWorktree] = useState<WorktreeInfo | null>(null); const [pullDialogWorktree, setPullDialogWorktree] = useState<WorktreeInfo | null>(null);
const [pullDialogRemote, setPullDialogRemote] = useState<string | undefined>(undefined); const [pullDialogRemote, setPullDialogRemote] = useState<string | undefined>(undefined);
// Remotes cache: maps worktree path -> list of remotes (fetched when dropdown opens)
const [remotesCache, setRemotesCache] = useState<
Record<string, Array<{ name: string; url: string }>>
>({});
const isMobile = useIsMobile(); const isMobile = useIsMobile();
// Periodic interval check (30 seconds) to detect branch changes on disk // Periodic interval check (30 seconds) to detect branch changes on disk
@@ -451,6 +478,21 @@ export function WorktreePanel({
}; };
}, [fetchWorktrees]); }, [fetchWorktrees]);
// Prune stale tracking-remote cache entries and remotes cache when worktrees change
useEffect(() => {
const activePaths = new Set(worktrees.map((w) => w.path));
pruneStaleEntries(activePaths);
setRemotesCache((prev) => {
const next: typeof prev = {};
for (const key of Object.keys(prev)) {
if (activePaths.has(key)) {
next[key] = prev[key];
}
}
return next;
});
}, [worktrees, pruneStaleEntries]);
const isWorktreeSelected = (worktree: WorktreeInfo) => { const isWorktreeSelected = (worktree: WorktreeInfo) => {
return worktree.isMain return worktree.isMain
? currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null ? currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null
@@ -467,6 +509,23 @@ export function WorktreePanel({
const handleActionsDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => { const handleActionsDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
if (open) { if (open) {
fetchBranches(worktree.path); fetchBranches(worktree.path);
// Fetch remotes for the submenu when the dropdown opens, but only if not already cached
if (!remotesCache[worktree.path]) {
const api = getHttpApiClient();
api.worktree
.listRemotes(worktree.path)
.then((result) => {
if (result.success && result.result) {
setRemotesCache((prev) => ({
...prev,
[worktree.path]: result.result!.remotes.map((r) => ({ name: r.name, url: r.url })),
}));
}
})
.catch((err) => {
console.warn('Failed to fetch remotes for worktree:', err);
});
}
} }
}; };
@@ -606,52 +665,101 @@ export function WorktreePanel({
setPushToRemoteDialogOpen(true); setPushToRemoteDialogOpen(true);
}, []); }, []);
// Handle pull completed - refresh worktrees // 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(() => { const handlePullCompleted = useCallback(() => {
// Refresh branch data (ahead/behind counts, tracking) and worktree list
// after GitPullDialog completes the pull operation
if (pullDialogWorktreeRef.current) {
fetchBranches(pullDialogWorktreeRef.current.path);
}
fetchWorktrees({ silent: true }); fetchWorktrees({ silent: true });
}, [fetchWorktrees]); }, [fetchWorktrees, fetchBranches]);
// Wrapper for onCommit that works with the pull dialog's simpler WorktreeInfo.
// Uses the full pullDialogWorktree when available (via ref to avoid making it
// a dep that would cascade into handleSuccessfulPull → checkForLocalChanges recreations).
const handleCommitMerge = useCallback(
(_simpleWorktree: { path: string; branch: string; isMain: boolean }) => {
// Prefer the full worktree object we already have (from ref)
if (pullDialogWorktreeRef.current) {
onCommit(pullDialogWorktreeRef.current);
}
},
[onCommit]
);
// Handle pull with remote selection when multiple remotes exist // Handle pull with remote selection when multiple remotes exist
// Now opens the pull dialog which handles stash management and conflict resolution // Now opens the pull dialog which handles stash management and conflict resolution
const handlePullWithRemoteSelection = useCallback(async (worktree: WorktreeInfo) => { // If the branch has a tracked remote, pull from it directly (skip the remote selection dialog)
try { const handlePullWithRemoteSelection = useCallback(
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result && result.result.remotes.length > 1) {
// Multiple remotes - show selection dialog first
setSelectRemoteWorktree(worktree);
setSelectRemoteOperation('pull');
setSelectRemoteDialogOpen(true);
} else if (result.success && result.result && result.result.remotes.length === 1) {
// Exactly one remote - open pull dialog directly with that remote
const remoteName = result.result.remotes[0].name;
setPullDialogRemote(remoteName);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
} else {
// No remotes - open pull dialog with default
setPullDialogRemote(undefined);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
}
} catch {
// If listing remotes fails, open pull dialog with default
setPullDialogRemote(undefined);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
}
}, []);
// Handle push with remote selection when multiple remotes exist
const handlePushWithRemoteSelection = useCallback(
async (worktree: WorktreeInfo) => { async (worktree: WorktreeInfo) => {
// If the branch already tracks a remote, pull from it directly — no dialog needed
const tracked = getTrackingRemote(worktree.path);
if (tracked) {
setPullDialogRemote(tracked);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
return;
}
try { try {
const api = getHttpApiClient(); const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path); const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result && result.result.remotes.length > 1) { if (result.success && result.result && result.result.remotes.length > 1) {
// Multiple remotes - show selection dialog // Multiple remotes and no tracking remote - show selection dialog
setSelectRemoteWorktree(worktree);
setSelectRemoteOperation('pull');
setSelectRemoteDialogOpen(true);
} else if (result.success && result.result && result.result.remotes.length === 1) {
// Exactly one remote - open pull dialog directly with that remote
const remoteName = result.result.remotes[0].name;
setPullDialogRemote(remoteName);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
} else {
// No remotes - open pull dialog with default
setPullDialogRemote(undefined);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
}
} catch {
// If listing remotes fails, open pull dialog with default
setPullDialogRemote(undefined);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
}
},
[getTrackingRemote]
);
// Handle push with remote selection when multiple remotes exist
// If the branch has a tracked remote, push to it directly (skip the remote selection dialog)
const handlePushWithRemoteSelection = useCallback(
async (worktree: WorktreeInfo) => {
// If the branch already tracks a remote, push to it directly — no dialog needed
const tracked = getTrackingRemote(worktree.path);
if (tracked) {
handlePush(worktree, tracked);
return;
}
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result && result.result.remotes.length > 1) {
// Multiple remotes and no tracking remote - show selection dialog
setSelectRemoteWorktree(worktree); setSelectRemoteWorktree(worktree);
setSelectRemoteOperation('push'); setSelectRemoteOperation('push');
setSelectRemoteDialogOpen(true); setSelectRemoteDialogOpen(true);
@@ -668,25 +776,44 @@ export function WorktreePanel({
handlePush(worktree); handlePush(worktree);
} }
}, },
[handlePush] [handlePush, getTrackingRemote]
); );
// Handle confirming remote selection for pull/push // Handle confirming remote selection for pull/push
const handleConfirmSelectRemote = useCallback( const handleConfirmSelectRemote = useCallback(
async (worktree: WorktreeInfo, remote: string) => { async (worktree: WorktreeInfo, remote: string) => {
if (selectRemoteOperation === 'pull') { if (selectRemoteOperation === 'pull') {
// Open the pull dialog with the selected remote // Open the pull dialog — let GitPullDialog manage the pull operation
// via its useEffect and onPulled callback (handlePullCompleted)
setPullDialogRemote(remote); setPullDialogRemote(remote);
setPullDialogWorktree(worktree); setPullDialogWorktree(worktree);
setPullDialogOpen(true); setPullDialogOpen(true);
await _handlePull(worktree, remote);
} else { } else {
await handlePush(worktree, remote); await handlePush(worktree, remote);
fetchBranches(worktree.path);
fetchWorktrees({ silent: true });
} }
fetchBranches(worktree.path);
fetchWorktrees();
}, },
[selectRemoteOperation, _handlePull, handlePush, fetchBranches, fetchWorktrees] [selectRemoteOperation, handlePush, fetchBranches, fetchWorktrees]
);
// Handle pull with a specific remote selected from the submenu (bypasses the remote selection dialog)
const handlePullWithSpecificRemote = useCallback((worktree: WorktreeInfo, remote: string) => {
// Open the pull dialog — let GitPullDialog manage the pull operation
// via its useEffect and onPulled callback (handlePullCompleted)
setPullDialogRemote(remote);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
}, []);
// Handle push to a specific remote selected from the submenu (bypasses the remote selection dialog)
const handlePushWithSpecificRemote = useCallback(
async (worktree: WorktreeInfo, remote: string) => {
await handlePush(worktree, remote);
fetchBranches(worktree.path);
fetchWorktrees({ silent: true });
},
[handlePush, fetchBranches, fetchWorktrees]
); );
// Handle confirming the push to remote dialog // Handle confirming the push to remote dialog
@@ -719,13 +846,13 @@ export function WorktreePanel({
setMergeDialogOpen(true); setMergeDialogOpen(true);
}, []); }, []);
// Handle merge completion - refresh worktrees and reassign features if branch was deleted // Handle integration completion - refresh worktrees and reassign features if branch was deleted
const handleMerged = useCallback( const handleIntegrated = useCallback(
(mergedWorktree: WorktreeInfo, deletedBranch: boolean) => { (integratedWorktree: WorktreeInfo, deletedBranch: boolean) => {
fetchWorktrees(); fetchWorktrees();
// If the branch was deleted, notify parent to reassign features to main // If the branch was deleted, notify parent to reassign features to main
if (deletedBranch && onBranchDeletedDuringMerge) { if (deletedBranch && onBranchDeletedDuringMerge) {
onBranchDeletedDuringMerge(mergedWorktree.branch); onBranchDeletedDuringMerge(integratedWorktree.branch);
} }
}, },
[fetchWorktrees, onBranchDeletedDuringMerge] [fetchWorktrees, onBranchDeletedDuringMerge]
@@ -777,6 +904,7 @@ export function WorktreePanel({
aheadCount={aheadCount} aheadCount={aheadCount}
behindCount={behindCount} behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch} hasRemoteBranch={hasRemoteBranch}
trackingRemote={getTrackingRemote(selectedWorktree.path)}
isPulling={isPulling} isPulling={isPulling}
isPushing={isPushing} isPushing={isPushing}
isStartingDevServer={isStartingDevServer} isStartingDevServer={isStartingDevServer}
@@ -789,10 +917,13 @@ export function WorktreePanel({
isStartingTests={isStartingTests} isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(selectedWorktree)} isTestRunning={isTestRunningForWorktree(selectedWorktree)}
testSessionInfo={getTestSessionInfo(selectedWorktree)} testSessionInfo={getTestSessionInfo(selectedWorktree)}
remotes={remotesCache[selectedWorktree.path]}
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)} onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
onPull={handlePullWithRemoteSelection} onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection} onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch} onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
onOpenInEditor={handleOpenInEditor} onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal} onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -802,6 +933,7 @@ export function WorktreePanel({
onCommit={onCommit} onCommit={onCommit}
onCreatePR={onCreatePR} onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments} onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts} onResolveConflicts={onResolveConflicts}
onMerge={handleMerge} onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree} onDeleteWorktree={onDeleteWorktree}
@@ -820,6 +952,9 @@ export function WorktreePanel({
onAbortOperation={handleAbortOperation} onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation} onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript} hasInitScript={hasInitScript}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
/> />
)} )}
@@ -924,6 +1059,7 @@ export function WorktreePanel({
remote={pullDialogRemote} remote={pullDialogRemote}
onPulled={handlePullCompleted} onPulled={handlePullCompleted}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature} onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
onCommitMerge={handleCommitMerge}
/> />
{/* Dev Server Logs Panel */} {/* Dev Server Logs Panel */}
@@ -952,13 +1088,13 @@ export function WorktreePanel({
onConfirm={handleConfirmSelectRemote} onConfirm={handleConfirmSelectRemote}
/> />
{/* Merge Branch Dialog */} {/* Integrate Branch Dialog */}
<MergeWorktreeDialog <MergeWorktreeDialog
open={mergeDialogOpen} open={mergeDialogOpen}
onOpenChange={setMergeDialogOpen} onOpenChange={setMergeDialogOpen}
projectPath={projectPath} projectPath={projectPath}
worktree={mergeWorktree} worktree={mergeWorktree}
onMerged={handleMerged} onIntegrated={handleIntegrated}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature} onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/> />
@@ -1019,6 +1155,8 @@ export function WorktreePanel({
aheadCount={aheadCount} aheadCount={aheadCount}
behindCount={behindCount} behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch} hasRemoteBranch={hasRemoteBranch}
trackingRemote={trackingRemote}
getTrackingRemote={getTrackingRemote}
gitRepoStatus={gitRepoStatus} gitRepoStatus={gitRepoStatus}
hasTestCommand={hasTestCommand} hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests} isStartingTests={isStartingTests}
@@ -1027,6 +1165,9 @@ export function WorktreePanel({
onPull={handlePullWithRemoteSelection} onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection} onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch} onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
remotesCache={remotesCache}
onOpenInEditor={handleOpenInEditor} onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal} onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -1036,6 +1177,7 @@ export function WorktreePanel({
onCommit={onCommit} onCommit={onCommit}
onCreatePR={onCreatePR} onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments} onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts} onResolveConflicts={onResolveConflicts}
onMerge={handleMerge} onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree} onDeleteWorktree={onDeleteWorktree}
@@ -1053,6 +1195,9 @@ export function WorktreePanel({
onCherryPick={handleCherryPick} onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation} onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation} onContinueOperation={handleContinueOperation}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
/> />
{useWorktreesEnabled && ( {useWorktreesEnabled && (
@@ -1112,6 +1257,7 @@ export function WorktreePanel({
aheadCount={aheadCount} aheadCount={aheadCount}
behindCount={behindCount} behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch} hasRemoteBranch={hasRemoteBranch}
trackingRemote={getTrackingRemote(mainWorktree.path)}
gitRepoStatus={gitRepoStatus} gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)} isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
isStartingTests={isStartingTests} isStartingTests={isStartingTests}
@@ -1126,6 +1272,9 @@ export function WorktreePanel({
onPull={handlePullWithRemoteSelection} onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection} onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch} onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
remotes={remotesCache[mainWorktree.path]}
onOpenInEditor={handleOpenInEditor} onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal} onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -1135,6 +1284,7 @@ export function WorktreePanel({
onCommit={onCommit} onCommit={onCommit}
onCreatePR={onCreatePR} onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments} onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts} onResolveConflicts={onResolveConflicts}
onMerge={handleMerge} onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree} onDeleteWorktree={onDeleteWorktree}
@@ -1154,6 +1304,9 @@ export function WorktreePanel({
onContinueOperation={handleContinueOperation} onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript} hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand} hasTestCommand={hasTestCommand}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
/> />
)} )}
</div> </div>
@@ -1191,6 +1344,7 @@ export function WorktreePanel({
aheadCount={aheadCount} aheadCount={aheadCount}
behindCount={behindCount} behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch} hasRemoteBranch={hasRemoteBranch}
trackingRemote={getTrackingRemote(worktree.path)}
gitRepoStatus={gitRepoStatus} gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)} isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
isStartingTests={isStartingTests} isStartingTests={isStartingTests}
@@ -1205,6 +1359,9 @@ export function WorktreePanel({
onPull={handlePullWithRemoteSelection} onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection} onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch} onPushNewBranch={handlePushNewBranch}
onPullWithRemote={handlePullWithSpecificRemote}
onPushWithRemote={handlePushWithSpecificRemote}
remotes={remotesCache[worktree.path]}
onOpenInEditor={handleOpenInEditor} onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal} onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -1214,6 +1371,7 @@ export function WorktreePanel({
onCommit={onCommit} onCommit={onCommit}
onCreatePR={onCreatePR} onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments} onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts} onResolveConflicts={onResolveConflicts}
onMerge={handleMerge} onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree} onDeleteWorktree={onDeleteWorktree}
@@ -1233,6 +1391,9 @@ export function WorktreePanel({
onContinueOperation={handleContinueOperation} onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript} hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand} hasTestCommand={hasTestCommand}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
/> />
); );
})} })}
@@ -1317,13 +1478,13 @@ export function WorktreePanel({
onConfirm={handleConfirmSelectRemote} onConfirm={handleConfirmSelectRemote}
/> />
{/* Merge Branch Dialog */} {/* Integrate Branch Dialog */}
<MergeWorktreeDialog <MergeWorktreeDialog
open={mergeDialogOpen} open={mergeDialogOpen}
onOpenChange={setMergeDialogOpen} onOpenChange={setMergeDialogOpen}
projectPath={projectPath} projectPath={projectPath}
worktree={mergeWorktree} worktree={mergeWorktree}
onMerged={handleMerged} onIntegrated={handleIntegrated}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature} onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/> />
@@ -1364,6 +1525,7 @@ export function WorktreePanel({
onOpenChange={setViewStashesDialogOpen} onOpenChange={setViewStashesDialogOpen}
worktree={viewStashesWorktree} worktree={viewStashesWorktree}
onStashApplied={handleStashApplied} onStashApplied={handleStashApplied}
onStashApplyConflict={onStashApplyConflict}
/> />
{/* Cherry Pick Dialog */} {/* Cherry Pick Dialog */}
@@ -1382,6 +1544,7 @@ export function WorktreePanel({
worktree={pullDialogWorktree} worktree={pullDialogWorktree}
remote={pullDialogRemote} remote={pullDialogRemote}
onPulled={handlePullCompleted} onPulled={handlePullCompleted}
onCommitMerge={handleCommitMerge}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature} onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/> />
</div> </div>

View File

@@ -42,8 +42,6 @@ import {
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const logger = createLogger('ContextView');
import { sanitizeFilename } from '@/lib/image-utils'; import { sanitizeFilename } from '@/lib/image-utils';
import { Markdown } from '../ui/markdown'; import { Markdown } from '../ui/markdown';
import { import {
@@ -54,6 +52,8 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
const logger = createLogger('ContextView');
interface ContextFile { interface ContextFile {
name: string; name: string;
type: 'text' | 'image'; type: 'text' | 'image';
@@ -973,7 +973,7 @@ export function ContextView() {
</div> </div>
{/* Content area */} {/* Content area */}
<div className="flex-1 overflow-hidden px-4 pb-4"> <div className="flex-1 overflow-hidden px-4 pb-2 sm:pb-4">
{selectedFile.type === 'image' ? ( {selectedFile.type === 'image' ? (
<div <div
className="h-full flex items-center justify-center bg-card rounded-lg" className="h-full flex items-center justify-center bg-card rounded-lg"

Some files were not shown because too many files have changed in this diff Show More