feat: Address review comments, add stage/unstage functionality, conflict resolution improvements, support for Sonnet 4.6

This commit is contained in:
gsxdsm
2026-02-18 18:58:33 -08:00
parent df9a6314da
commit 983eb21faa
66 changed files with 2317 additions and 823 deletions

View File

@@ -6,7 +6,12 @@
* import from here rather than defining their own copy.
*/
import fs from 'fs/promises';
import path from 'path';
import { spawnProcess } from '@automaker/platform';
import { createLogger } from '@automaker/utils';
const logger = createLogger('GitLib');
// ============================================================================
// Secure Command Execution
@@ -80,3 +85,110 @@ export async function getCurrentBranch(worktreePath: string): Promise<string> {
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
return branchOutput.trim();
}
// ============================================================================
// Index Lock Recovery
// ============================================================================
/**
* Check whether an error message indicates a stale git index lock file.
*
* Git operations that write to the index (e.g. `git stash push`) will fail
* with "could not write index" or "Unable to create ... .lock" when a
* `.git/index.lock` file exists from a previously interrupted operation.
*
* @param errorMessage - The error string from a failed git command
* @returns true if the error looks like a stale index lock issue
*/
export function isIndexLockError(errorMessage: string): boolean {
const lower = errorMessage.toLowerCase();
return (
lower.includes('could not write index') ||
(lower.includes('unable to create') && lower.includes('index.lock')) ||
lower.includes('index.lock')
);
}
/**
* Attempt to remove a stale `.git/index.lock` file for the given worktree.
*
* Uses `git rev-parse --git-dir` to locate the correct `.git` directory,
* which works for both regular repositories and linked worktrees.
*
* @param worktreePath - Path to the git worktree (or main repo)
* @returns true if a lock file was found and removed, false otherwise
*/
export async function removeStaleIndexLock(worktreePath: string): Promise<boolean> {
try {
// Resolve the .git directory (handles worktrees correctly)
const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath);
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
const lockFilePath = path.join(gitDir, 'index.lock');
// Check if the lock file exists
try {
await fs.access(lockFilePath);
} catch {
// Lock file does not exist — nothing to remove
return false;
}
// Remove the stale lock file
await fs.unlink(lockFilePath);
logger.info('Removed stale index.lock file', { worktreePath, lockFilePath });
return true;
} catch (err) {
logger.warn('Failed to remove stale index.lock file', {
worktreePath,
error: err instanceof Error ? err.message : String(err),
});
return false;
}
}
/**
* Execute a git command with automatic retry when a stale index.lock is detected.
*
* If the command fails with an error indicating a locked index file, this
* helper will attempt to remove the stale `.git/index.lock` and retry the
* command exactly once.
*
* This is particularly useful for `git stash push` which writes to the
* index and commonly fails when a previous git operation was interrupted.
*
* @param args - Array of git command arguments
* @param cwd - Working directory to execute the command in
* @param env - Optional additional environment variables
* @returns Promise resolving to stdout output
* @throws The original error if retry also fails, or a non-lock error
*/
export async function execGitCommandWithLockRetry(
args: string[],
cwd: string,
env?: Record<string, string>
): Promise<string> {
try {
return await execGitCommand(args, cwd, env);
} catch (error: unknown) {
const err = error as { message?: string; stderr?: string };
const errorMessage = err.stderr || err.message || '';
if (!isIndexLockError(errorMessage)) {
throw error;
}
logger.info('Git command failed due to index lock, attempting cleanup and retry', {
cwd,
args: args.join(' '),
});
const removed = await removeStaleIndexLock(cwd);
if (!removed) {
// Could not remove the lock file — re-throw the original error
throw error;
}
// Retry the command once after removing the lock file
return await execGitCommand(args, cwd, env);
}
}

View File

@@ -343,6 +343,18 @@ export class ClaudeProvider extends BaseProvider {
tier: 'premium' as const,
default: true,
},
{
id: 'claude-sonnet-4-6',
name: 'Claude Sonnet 4.6',
modelString: 'claude-sonnet-4-6',
provider: 'anthropic',
description: 'Balanced performance and cost with enhanced reasoning',
contextWindow: 200000,
maxOutputTokens: 128000,
supportsVision: true,
supportsTools: true,
tier: 'standard' as const,
},
{
id: 'claude-sonnet-4-20250514',
name: 'Claude Sonnet 4',

View File

@@ -245,15 +245,9 @@ async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<Codex
throw new Error(ERROR_CODEX_CLI_REQUIRED);
}
if (!cliAuthenticated) {
throw new Error(ERROR_CODEX_AUTH_REQUIRED);
}
return {
mode: CODEX_EXECUTION_MODE_CLI,
cliPath,
openAiApiKey,
};
// At this point, neither hasCliNativeAuth nor hasApiKey is true,
// so authentication is required regardless.
throw new Error(ERROR_CODEX_AUTH_REQUIRED);
}
function getEventType(event: Record<string, unknown>): string | null {

View File

@@ -15,6 +15,9 @@ const SDK_HISTORY_HEADER = 'Current request:\n';
const DEFAULT_RESPONSE_TEXT = '';
const SDK_ERROR_DETAILS_LABEL = 'Details:';
type SdkReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
const SDK_REASONING_EFFORTS = new Set<string>(['minimal', 'low', 'medium', 'high', 'xhigh']);
type PromptBlock = {
type: string;
text?: string;
@@ -103,9 +106,6 @@ export async function* executeCodexSdkQuery(
// The model must be passed to startThread/resumeThread so the SDK
// knows which model to use for the conversation. Without this,
// the SDK may use a default model that the user doesn't have access to.
type SdkReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
const SDK_REASONING_EFFORTS = new Set<string>(['minimal', 'low', 'medium', 'high', 'xhigh']);
const threadOptions: {
model?: string;
modelReasoningEffort?: SdkReasoningEffort;
@@ -118,6 +118,7 @@ export async function* executeCodexSdkQuery(
// Add reasoning effort to thread options if model supports it
if (
options.reasoningEffort &&
options.model &&
supportsReasoningEffort(options.model) &&
options.reasoningEffort !== 'none' &&
SDK_REASONING_EFFORTS.has(options.reasoningEffort)

View File

@@ -42,7 +42,7 @@ import {
const logger = createLogger('CopilotProvider');
// Default bare model (without copilot- prefix) for SDK calls
const DEFAULT_BARE_MODEL = 'claude-sonnet-4.5';
const DEFAULT_BARE_MODEL = 'claude-sonnet-4.6';
// =============================================================================
// SDK Event Types (from @github/copilot-sdk)

View File

@@ -549,8 +549,15 @@ export class OpencodeProvider extends CliProvider {
// sdkSessionId IS set — the CLI will receive `--session <id>`.
// If that session no longer exists, intercept the error and retry fresh.
//
// To avoid buffering the entire stream in memory for long-lived sessions,
// we only buffer an initial window of messages until we observe a healthy
// (non-error) message. Once a healthy message is seen, we flush the buffer
// and switch to direct passthrough, while still watching for session errors
// via isSessionNotFoundError on any subsequent error messages.
const buffered: ProviderMessage[] = [];
let sessionError = false;
let seenHealthyMessage = false;
try {
for await (const msg of super.executeQuery(options)) {
@@ -565,13 +572,30 @@ export class OpencodeProvider extends CliProvider {
break; // stop consuming the failed stream
}
// Non-session error — clean and buffer
// Non-session error — clean it
if (msg.error && typeof msg.error === 'string') {
msg.error = OpencodeProvider.cleanErrorMessage(msg.error);
}
} else {
// A non-error message is a healthy signal — stop buffering after this
seenHealthyMessage = true;
}
buffered.push(msg);
if (seenHealthyMessage && buffered.length > 0) {
// Flush the pre-healthy buffer first, then switch to passthrough
for (const bufferedMsg of buffered) {
yield bufferedMsg;
}
buffered.length = 0;
}
if (seenHealthyMessage) {
// Passthrough mode — yield directly without buffering
yield msg;
} else {
// Still in initial window — buffer until we see a healthy message
buffered.push(msg);
}
}
} catch (error) {
// Also handle thrown exceptions (e.g. from mapError in cli-provider)
@@ -602,12 +626,15 @@ export class OpencodeProvider extends CliProvider {
}
yield retryMsg;
}
} else {
// No session error — flush buffered messages to the consumer
} else if (buffered.length > 0) {
// No session error and still have buffered messages (stream ended before
// any healthy message was observed) — flush them to the consumer
for (const msg of buffered) {
yield msg;
}
}
// If seenHealthyMessage is true, all messages have already been yielded
// directly in passthrough mode — nothing left to flush.
}
/**
@@ -673,7 +700,7 @@ export class OpencodeProvider extends CliProvider {
return {
type: 'error',
session_id: finishEvent.sessionID,
error: finishEvent.part.error,
error: OpencodeProvider.cleanErrorMessage(finishEvent.part.error),
};
}
@@ -682,7 +709,7 @@ export class OpencodeProvider extends CliProvider {
return {
type: 'error',
session_id: finishEvent.sessionID,
error: 'Step execution failed',
error: OpencodeProvider.cleanErrorMessage('Step execution failed'),
};
}
@@ -705,8 +732,10 @@ export class OpencodeProvider extends CliProvider {
case 'tool_error': {
const toolErrorEvent = openCodeEvent as OpenCodeBaseEvent;
// Extract error message from part.error
const errorMessage = toolErrorEvent.part?.error || 'Tool execution failed';
// Extract error message from part.error and clean ANSI codes
const errorMessage = OpencodeProvider.cleanErrorMessage(
toolErrorEvent.part?.error || 'Tool execution failed'
);
return {
type: 'error',
@@ -719,16 +748,8 @@ export class OpencodeProvider extends CliProvider {
// The event format includes the tool name, call ID, and state with input/output.
// Handle both 'tool_use' (actual CLI format) and 'tool_call' (legacy/alternative) for robustness.
case 'tool_use': {
const toolUseEvent = openCodeEvent as OpenCodeBaseEvent;
const part = toolUseEvent.part as OpenCodePart & {
callID?: string;
tool?: string;
state?: {
status?: string;
input?: unknown;
output?: string;
};
};
const toolUseEvent = openCodeEvent as OpenCodeToolUseEvent;
const part = toolUseEvent.part;
// Generate a tool use ID if not provided
const toolUseId = part?.callID || part?.call_id || generateToolUseId();
@@ -898,9 +919,9 @@ export class OpencodeProvider extends CliProvider {
default: true,
},
{
id: 'opencode/glm-4.7-free',
name: 'GLM 4.7 Free',
modelString: 'opencode/glm-4.7-free',
id: 'opencode/glm-5-free',
name: 'GLM 5 Free',
modelString: 'opencode/glm-5-free',
provider: 'opencode',
description: 'OpenCode free tier GLM model',
supportsTools: true,
@@ -918,19 +939,19 @@ export class OpencodeProvider extends CliProvider {
tier: 'basic',
},
{
id: 'opencode/grok-code',
name: 'Grok Code (Free)',
modelString: 'opencode/grok-code',
id: 'opencode/kimi-k2.5-free',
name: 'Kimi K2.5 Free',
modelString: 'opencode/kimi-k2.5-free',
provider: 'opencode',
description: 'OpenCode free tier Grok model for coding',
description: 'OpenCode free tier Kimi model for coding',
supportsTools: true,
supportsVision: false,
tier: 'basic',
},
{
id: 'opencode/minimax-m2.1-free',
name: 'MiniMax M2.1 Free',
modelString: 'opencode/minimax-m2.1-free',
id: 'opencode/minimax-m2.5-free',
name: 'MiniMax M2.5 Free',
modelString: 'opencode/minimax-m2.5-free',
provider: 'opencode',
description: 'OpenCode free tier MiniMax model',
supportsTools: true,
@@ -1052,7 +1073,7 @@ export class OpencodeProvider extends CliProvider {
*
* OpenCode CLI output format (one model per line):
* opencode/big-pickle
* opencode/glm-4.7-free
* opencode/glm-5-free
* anthropic/claude-3-5-haiku-20241022
* github-copilot/claude-3.5-sonnet
* ...

View File

@@ -94,7 +94,7 @@ export interface StreamingQueryOptions extends SimpleQueryOptions {
/**
* Default model to use when none specified
*/
const DEFAULT_MODEL = 'claude-sonnet-4-20250514';
const DEFAULT_MODEL = 'claude-sonnet-4-6';
/**
* Execute a simple query and return the text result

View File

@@ -6,12 +6,14 @@ import { Router } from 'express';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createDiffsHandler } from './routes/diffs.js';
import { createFileDiffHandler } from './routes/file-diff.js';
import { createStageFilesHandler } from './routes/stage-files.js';
export function createGitRoutes(): Router {
const router = Router();
router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler());
router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler());
router.post('/stage-files', validatePathParams('projectPath'), createStageFilesHandler());
return router;
}

View File

@@ -0,0 +1,60 @@
/**
* POST /stage-files endpoint - Stage or unstage files in the main project
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
export function createStageFilesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, files, operation } = req.body as {
projectPath: string;
files: string[];
operation: 'stage' | 'unstage';
};
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath required',
});
return;
}
if (!files || files.length === 0) {
res.status(400).json({
success: false,
error: 'files array required and must not be empty',
});
return;
}
if (operation !== 'stage' && operation !== 'unstage') {
res.status(400).json({
success: false,
error: 'operation must be "stage" or "unstage"',
});
return;
}
if (operation === 'stage') {
await execGitCommand(['add', '--', ...files], projectPath);
} else {
await execGitCommand(['reset', 'HEAD', '--', ...files], projectPath);
}
res.json({
success: true,
result: {
operation,
filesCount: files.length,
},
});
} catch (error) {
logError(error, `${(req.body as { operation?: string })?.operation ?? 'stage'} files failed`);
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -158,7 +158,7 @@ export function createVerifyClaudeAuthHandler() {
const stream = query({
prompt: "Reply with only the word 'ok'",
options: {
model: 'claude-sonnet-4-20250514',
model: 'claude-sonnet-4-6',
maxTurns: 1,
allowedTools: [],
abortController,

View File

@@ -63,6 +63,9 @@ import { createCherryPickHandler } from './routes/cherry-pick.js';
import { createBranchCommitLogHandler } from './routes/branch-commit-log.js';
import { createGeneratePRDescriptionHandler } from './routes/generate-pr-description.js';
import { createRebaseHandler } from './routes/rebase.js';
import { createAbortOperationHandler } from './routes/abort-operation.js';
import { createContinueOperationHandler } from './routes/continue-operation.js';
import { createStageFilesHandler } from './routes/stage-files.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createWorktreeRoutes(
@@ -276,5 +279,29 @@ export function createWorktreeRoutes(
createRebaseHandler(events)
);
// Abort in-progress merge/rebase/cherry-pick
router.post(
'/abort-operation',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createAbortOperationHandler(events)
);
// Continue in-progress merge/rebase/cherry-pick after resolving conflicts
router.post(
'/continue-operation',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createContinueOperationHandler(events)
);
// Stage/unstage files route
router.post(
'/stage-files',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createStageFilesHandler()
);
return router;
}

View File

@@ -0,0 +1,117 @@
/**
* POST /abort-operation endpoint - Abort an in-progress merge, rebase, or cherry-pick
*
* Detects which operation (merge, rebase, or cherry-pick) is in progress
* and aborts it, returning the repository to a clean state.
*/
import type { Request, Response } from 'express';
import path from 'path';
import * as fs from 'fs/promises';
import { getErrorMessage, logError, execAsync } from '../common.js';
import type { EventEmitter } from '../../../lib/events.js';
/**
* Detect what type of conflict operation is currently in progress
*/
async function detectOperation(
worktreePath: string
): Promise<'merge' | 'rebase' | 'cherry-pick' | null> {
try {
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', {
cwd: worktreePath,
});
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
const [rebaseMergeExists, rebaseApplyExists, mergeHeadExists, cherryPickHeadExists] =
await Promise.all([
fs
.access(path.join(gitDir, 'rebase-merge'))
.then(() => true)
.catch(() => false),
fs
.access(path.join(gitDir, 'rebase-apply'))
.then(() => true)
.catch(() => false),
fs
.access(path.join(gitDir, 'MERGE_HEAD'))
.then(() => true)
.catch(() => false),
fs
.access(path.join(gitDir, 'CHERRY_PICK_HEAD'))
.then(() => true)
.catch(() => false),
]);
if (rebaseMergeExists || rebaseApplyExists) return 'rebase';
if (mergeHeadExists) return 'merge';
if (cherryPickHeadExists) return 'cherry-pick';
return null;
} catch {
return null;
}
}
export function createAbortOperationHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath is required',
});
return;
}
const resolvedWorktreePath = path.resolve(worktreePath);
// Detect what operation is in progress
const operation = await detectOperation(resolvedWorktreePath);
if (!operation) {
res.status(400).json({
success: false,
error: 'No merge, rebase, or cherry-pick in progress',
});
return;
}
// Abort the operation
let abortCommand: string;
switch (operation) {
case 'merge':
abortCommand = 'git merge --abort';
break;
case 'rebase':
abortCommand = 'git rebase --abort';
break;
case 'cherry-pick':
abortCommand = 'git cherry-pick --abort';
break;
}
await execAsync(abortCommand, { cwd: resolvedWorktreePath });
// Emit event
events.emit('conflict:aborted', {
worktreePath: resolvedWorktreePath,
operation,
});
res.json({
success: true,
result: {
operation,
message: `${operation.charAt(0).toUpperCase() + operation.slice(1)} aborted successfully`,
},
});
} catch (error) {
logError(error, 'Abort operation failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,151 @@
/**
* POST /continue-operation endpoint - Continue an in-progress merge, rebase, or cherry-pick
*
* After conflicts have been resolved, this endpoint continues the operation.
* For merge: performs git commit (merge is auto-committed after conflict resolution)
* For rebase: runs git rebase --continue
* For cherry-pick: runs git cherry-pick --continue
*/
import type { Request, Response } from 'express';
import path from 'path';
import * as fs from 'fs/promises';
import { getErrorMessage, logError, execAsync } from '../common.js';
import type { EventEmitter } from '../../../lib/events.js';
/**
* Detect what type of conflict operation is currently in progress
*/
async function detectOperation(
worktreePath: string
): Promise<'merge' | 'rebase' | 'cherry-pick' | null> {
try {
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', {
cwd: worktreePath,
});
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
const [rebaseMergeExists, rebaseApplyExists, mergeHeadExists, cherryPickHeadExists] =
await Promise.all([
fs
.access(path.join(gitDir, 'rebase-merge'))
.then(() => true)
.catch(() => false),
fs
.access(path.join(gitDir, 'rebase-apply'))
.then(() => true)
.catch(() => false),
fs
.access(path.join(gitDir, 'MERGE_HEAD'))
.then(() => true)
.catch(() => false),
fs
.access(path.join(gitDir, 'CHERRY_PICK_HEAD'))
.then(() => true)
.catch(() => false),
]);
if (rebaseMergeExists || rebaseApplyExists) return 'rebase';
if (mergeHeadExists) return 'merge';
if (cherryPickHeadExists) return 'cherry-pick';
return null;
} catch {
return null;
}
}
/**
* Check if there are still unmerged paths (unresolved conflicts)
*/
async function hasUnmergedPaths(worktreePath: string): Promise<boolean> {
try {
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
cwd: worktreePath,
});
return statusOutput.split('\n').some((line) => /^(UU|AA|DD|AU|UA|DU|UD)/.test(line));
} catch {
return false;
}
}
export function createContinueOperationHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath is required',
});
return;
}
const resolvedWorktreePath = path.resolve(worktreePath);
// Detect what operation is in progress
const operation = await detectOperation(resolvedWorktreePath);
if (!operation) {
res.status(400).json({
success: false,
error: 'No merge, rebase, or cherry-pick in progress',
});
return;
}
// Check for unresolved conflicts
if (await hasUnmergedPaths(resolvedWorktreePath)) {
res.status(409).json({
success: false,
error:
'There are still unresolved conflicts. Please resolve all conflicts before continuing.',
hasUnresolvedConflicts: true,
});
return;
}
// Stage all resolved files first
await execAsync('git add -A', { cwd: resolvedWorktreePath });
// Continue the operation
let continueCommand: string;
switch (operation) {
case 'merge':
// For merge, we need to commit after resolving conflicts
continueCommand = 'git commit --no-edit';
break;
case 'rebase':
continueCommand = 'git rebase --continue';
break;
case 'cherry-pick':
continueCommand = 'git cherry-pick --continue';
break;
}
await execAsync(continueCommand, {
cwd: resolvedWorktreePath,
env: { ...process.env, GIT_EDITOR: 'true' }, // Prevent editor from opening
});
// Emit event
events.emit('conflict:resolved', {
worktreePath: resolvedWorktreePath,
operation,
});
res.json({
success: true,
result: {
operation,
message: `${operation.charAt(0).toUpperCase() + operation.slice(1)} continued successfully`,
},
});
} catch (error) {
logError(error, 'Continue operation failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -58,6 +58,88 @@ interface WorktreeInfo {
hasChanges?: boolean;
changedFilesCount?: number;
pr?: WorktreePRInfo; // PR info if a PR has been created for this branch
/** Whether a merge or rebase is in progress (has conflicts) */
hasConflicts?: boolean;
/** Type of conflict operation in progress */
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
/** List of files with conflicts */
conflictFiles?: string[];
}
/**
* Detect if a merge, rebase, or cherry-pick is in progress for a worktree.
* Checks for the presence of state files/directories that git creates
* during these operations.
*/
async function detectConflictState(worktreePath: string): Promise<{
hasConflicts: boolean;
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
conflictFiles?: string[];
}> {
try {
// Find the canonical .git directory for this worktree
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', {
cwd: worktreePath,
});
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
// Check for merge, rebase, and cherry-pick state files/directories
const [mergeHeadExists, rebaseMergeExists, rebaseApplyExists, cherryPickHeadExists] =
await Promise.all([
secureFs
.access(path.join(gitDir, 'MERGE_HEAD'))
.then(() => true)
.catch(() => false),
secureFs
.access(path.join(gitDir, 'rebase-merge'))
.then(() => true)
.catch(() => false),
secureFs
.access(path.join(gitDir, 'rebase-apply'))
.then(() => true)
.catch(() => false),
secureFs
.access(path.join(gitDir, 'CHERRY_PICK_HEAD'))
.then(() => true)
.catch(() => false),
]);
let conflictType: 'merge' | 'rebase' | 'cherry-pick' | undefined;
if (rebaseMergeExists || rebaseApplyExists) {
conflictType = 'rebase';
} else if (mergeHeadExists) {
conflictType = 'merge';
} else if (cherryPickHeadExists) {
conflictType = 'cherry-pick';
}
if (!conflictType) {
return { hasConflicts: false };
}
// Get list of conflicted files using machine-readable git status
let conflictFiles: string[] = [];
try {
const { stdout: statusOutput } = await execAsync('git diff --name-only --diff-filter=U', {
cwd: worktreePath,
});
conflictFiles = statusOutput
.trim()
.split('\n')
.filter((f) => f.trim().length > 0);
} catch {
// Fall back to empty list if diff fails
}
return {
hasConflicts: true,
conflictType,
conflictFiles,
};
} catch {
// If anything fails, assume no conflicts
return { hasConflicts: false };
}
}
async function getCurrentBranch(cwd: string): Promise<string> {
@@ -373,7 +455,7 @@ export function createListHandler() {
// Read all worktree metadata to get PR info
const allMetadata = await readAllWorktreeMetadata(projectPath);
// If includeDetails is requested, fetch change status for each worktree
// If includeDetails is requested, fetch change status and conflict state for each worktree
if (includeDetails) {
for (const worktree of worktrees) {
try {
@@ -390,6 +472,18 @@ export function createListHandler() {
worktree.hasChanges = false;
worktree.changedFilesCount = 0;
}
// Detect merge/rebase/cherry-pick in progress
try {
const conflictState = await detectConflictState(worktree.path);
if (conflictState.hasConflicts) {
worktree.hasConflicts = true;
worktree.conflictType = conflictState.conflictType;
worktree.conflictFiles = conflictState.conflictFiles;
}
} catch {
// Ignore conflict detection errors
}
}
}

View File

@@ -0,0 +1,69 @@
/**
* POST /stage-files endpoint - Stage or unstage files in a worktree
*
* Supports two operations:
* 1. Stage files: `git add <files>` (adds files to the staging area)
* 2. Unstage files: `git reset HEAD -- <files>` (removes files from staging area)
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
export function createStageFilesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, files, operation } = req.body as {
worktreePath: string;
files: string[];
operation: 'stage' | 'unstage';
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
if (!files || files.length === 0) {
res.status(400).json({
success: false,
error: 'files array required and must not be empty',
});
return;
}
if (operation !== 'stage' && operation !== 'unstage') {
res.status(400).json({
success: false,
error: 'operation must be "stage" or "unstage"',
});
return;
}
if (operation === 'stage') {
// Stage the specified files
await execGitCommand(['add', '--', ...files], worktreePath);
} else {
// Unstage the specified files
await execGitCommand(['reset', 'HEAD', '--', ...files], worktreePath);
}
res.json({
success: true,
result: {
operation,
filesCount: files.length,
},
});
} catch (error) {
logError(error, `${(req.body as { operation?: string })?.operation ?? 'stage'} files failed`);
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -54,7 +54,7 @@ export function createStashApplyHandler(events: EventEmitter) {
const result = await applyOrPop(worktreePath, idx, { pop }, events);
if (!result.success) {
logError(new Error(result.error ?? 'Stash apply failed'), 'Stash apply failed');
// applyOrPop already logs the error internally via logError — no need to double-log here
res.status(500).json({ success: false, error: result.error });
return;
}

View File

@@ -42,6 +42,27 @@ export class AgentExecutor {
private static readonly WRITE_DEBOUNCE_MS = 500;
private static readonly STREAM_HEARTBEAT_MS = 15_000;
/**
* Sanitize a provider error value into clean text.
* Coalesces to string, removes ANSI codes, strips leading "Error:" prefix,
* trims, and returns 'Unknown error' when empty.
*/
private static sanitizeProviderError(input: string | { error?: string } | undefined): string {
let raw: string;
if (typeof input === 'string') {
raw = input;
} else if (input && typeof input === 'object' && typeof input.error === 'string') {
raw = input.error;
} else {
raw = '';
}
const cleaned = raw
.replace(/\x1b\[[0-9;]*m/g, '')
.replace(/^Error:\s*/i, '')
.trim();
return cleaned || 'Unknown error';
}
constructor(
private eventBus: TypedEventBus,
private featureStateManager: FeatureStateManager,
@@ -255,15 +276,7 @@ export class AgentExecutor {
}
}
} else if (msg.type === 'error') {
// Clean the error: strip ANSI codes and the redundant "Error: " prefix
// that CLI providers add. Without this, wrapping in new Error() produces
// "Error: Error: Session not found" (double-prefixed).
const cleanedError =
(msg.error || 'Unknown error')
.replace(/\x1b\[[0-9;]*m/g, '')
.replace(/^Error:\s*/i, '')
.trim() || 'Unknown error';
throw new Error(cleanedError);
throw new Error(AgentExecutor.sanitizeProviderError(msg.error));
} else if (msg.type === 'result' && msg.subtype === 'success') scheduleWrite();
}
await writeToFile();

View File

@@ -96,6 +96,20 @@ export class AgentService {
await secureFs.mkdir(this.stateDir, { recursive: true });
}
/**
* Detect provider-side session errors (session not found, expired, etc.).
* Used to decide whether to clear a stale sdkSessionId.
*/
private isStaleSessionError(rawErrorText: string): boolean {
const errorLower = rawErrorText.toLowerCase();
return (
errorLower.includes('session not found') ||
errorLower.includes('session expired') ||
errorLower.includes('invalid session') ||
errorLower.includes('no such session')
);
}
/**
* Start or resume a conversation
*/
@@ -195,7 +209,15 @@ export class AgentService {
const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory);
// Validate that the working directory is allowed using centralized validation
validateWorkingDirectory(resolvedWorkingDirectory);
try {
validateWorkingDirectory(resolvedWorkingDirectory);
} catch (validationError) {
this.logger.warn(
`Session "${sessionId}": working directory "${resolvedWorkingDirectory}" is not allowed — ` +
`returning null so callers treat it as a missing session. Error: ${(validationError as Error).message}`
);
return null;
}
// Load persisted queue
const promptQueue = await this.loadQueueState(sessionId);
@@ -411,7 +433,7 @@ export class AgentService {
// When using a custom provider (GLM, MiniMax), use resolved Claude model for SDK config
// (thinking level budgets, allowedTools) but we MUST pass the provider's model ID
// (e.g. "GLM-4.7") to the API - not "claude-sonnet-4-20250514" which causes "model not found"
// (e.g. "GLM-4.7") to the API - not "claude-sonnet-4-6" which causes "model not found"
const modelForSdk = providerResolvedModel || model;
const sessionModelForSdk = providerResolvedModel ? undefined : session.model;
@@ -616,14 +638,7 @@ export class AgentService {
// sdkSessionId so the next attempt starts a fresh provider session.
// This handles providers that don't have built-in session recovery
// (unlike OpenCode which auto-retries without the session flag).
const errorLower = rawErrorText.toLowerCase();
if (
session.sdkSessionId &&
(errorLower.includes('session not found') ||
errorLower.includes('session expired') ||
errorLower.includes('invalid session') ||
errorLower.includes('no such session'))
) {
if (session.sdkSessionId && this.isStaleSessionError(rawErrorText)) {
this.logger.info(
`Clearing stale sdkSessionId for session ${sessionId} after provider session error`
);
@@ -699,13 +714,7 @@ export class AgentService {
// Check if the thrown error is a provider-side session error.
// Clear the stale sdkSessionId so the next retry starts fresh.
if (
session.sdkSessionId &&
(thrownErrorMsg.includes('session not found') ||
thrownErrorMsg.includes('session expired') ||
thrownErrorMsg.includes('invalid session') ||
thrownErrorMsg.includes('no such session'))
) {
if (session.sdkSessionId && this.isStaleSessionError(rawThrownMsg)) {
this.logger.info(
`Clearing stale sdkSessionId for session ${sessionId} after thrown session error`
);

View File

@@ -208,7 +208,7 @@ export class AutoModeServiceFacade {
model?: string,
opts?: Record<string, unknown>
) => {
const resolvedModel = model || 'claude-sonnet-4-20250514';
const resolvedModel = model || 'claude-sonnet-4-6';
const provider = ProviderFactory.getProviderForModel(resolvedModel);
const effectiveBareModel = stripProviderPrefix(resolvedModel);
@@ -258,7 +258,7 @@ export class AutoModeServiceFacade {
featureStateManager.saveFeatureSummary(projPath, fId, summary),
buildTaskPrompt: (task, allTasks, taskIndex, _planContent, template, feedback) => {
let taskPrompt = template
.replace(/\{\{taskName\}\}/g, task.description)
.replace(/\{\{taskName\}\}/g, task.description || `Task ${task.id}`)
.replace(/\{\{taskIndex\}\}/g, String(taskIndex + 1))
.replace(/\{\{totalTasks\}\}/g, String(allTasks.length))
.replace(/\{\{taskDescription\}\}/g, task.description || `Task ${task.id}`);
@@ -336,7 +336,7 @@ export class AutoModeServiceFacade {
branchName?: string | null;
}
) => {
const resolvedModel = model || 'claude-sonnet-4-20250514';
const resolvedModel = model || 'claude-sonnet-4-6';
const provider = ProviderFactory.getProviderForModel(resolvedModel);
const effectiveBareModel = stripProviderPrefix(resolvedModel);
@@ -385,7 +385,7 @@ export class AutoModeServiceFacade {
featureStateManager.saveFeatureSummary(projPath, fId, summary),
buildTaskPrompt: (task, allTasks, taskIndex, planContent, template, feedback) => {
let taskPrompt = template
.replace(/\{\{taskName\}\}/g, task.description)
.replace(/\{\{taskName\}\}/g, task.description || `Task ${task.id}`)
.replace(/\{\{taskIndex\}\}/g, String(taskIndex + 1))
.replace(/\{\{totalTasks\}\}/g, String(allTasks.length))
.replace(/\{\{taskDescription\}\}/g, task.description || `Task ${task.id}`);

View File

@@ -35,7 +35,10 @@ export interface MergeServiceResult {
*/
function isValidBranchName(name: string): boolean {
// First char must be alphanumeric, dot, underscore, or slash (not dash)
return /^[a-zA-Z0-9._/][a-zA-Z0-9._\-/]*$/.test(name) && name.length < 250;
// Reject names containing '..' to prevent git ref traversal
return (
/^[a-zA-Z0-9._/][a-zA-Z0-9._\-/]*$/.test(name) && name.length < 250 && !name.includes('..')
);
}
/**

View File

@@ -16,7 +16,7 @@
*/
import { createLogger } from '@automaker/utils';
import { execGitCommand } from '../lib/git.js';
import { execGitCommand, execGitCommandWithLockRetry } from '../lib/git.js';
import { getErrorMessage } from '../routes/worktree/common.js';
const logger = createLogger('PullService');
@@ -106,7 +106,10 @@ export async function getLocalChanges(
*/
export async function stashChanges(worktreePath: string, branchName: string): Promise<void> {
const stashMessage = `automaker-pull-stash: Pre-pull stash on ${branchName}`;
await execGitCommand(['stash', 'push', '--include-untracked', '-m', stashMessage], worktreePath);
await execGitCommandWithLockRetry(
['stash', 'push', '--include-untracked', '-m', stashMessage],
worktreePath
);
}
/**

View File

@@ -16,7 +16,7 @@
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../lib/events.js';
import { execGitCommand } from '../lib/git.js';
import { execGitCommand, execGitCommandWithLockRetry } from '../lib/git.js';
import { getErrorMessage, logError } from '../routes/worktree/common.js';
const logger = createLogger('StashService');
@@ -105,6 +105,46 @@ function isConflictOutput(output: string): boolean {
return output.includes('CONFLICT') || output.includes('Merge conflict');
}
/**
* Build a conflict result from stash apply/pop, emit events, and return.
* Extracted to avoid duplicating conflict handling in the try and catch paths.
*/
async function handleStashConflicts(
worktreePath: string,
stashIndex: number,
operation: 'apply' | 'pop',
events?: EventEmitter
): Promise<StashApplyResult> {
const conflictFiles = await getConflictedFiles(worktreePath);
events?.emit('stash:conflicts', {
worktreePath,
stashIndex,
operation,
conflictFiles,
});
const result: StashApplyResult = {
success: true,
applied: true,
hasConflicts: true,
conflictFiles,
operation,
stashIndex,
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`,
};
events?.emit('stash:success', {
worktreePath,
stashIndex,
operation,
hasConflicts: true,
conflictFiles,
});
return result;
}
// ============================================================================
// Main Service Function
// ============================================================================
@@ -164,34 +204,7 @@ export async function applyOrPop(
// 4. Check if the error is a conflict
if (isConflictOutput(combinedOutput)) {
const conflictFiles = await getConflictedFiles(worktreePath);
events?.emit('stash:conflicts', {
worktreePath,
stashIndex,
operation,
conflictFiles,
});
const result: StashApplyResult = {
success: true,
applied: true,
hasConflicts: true,
conflictFiles,
operation,
stashIndex,
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`,
};
events?.emit('stash:success', {
worktreePath,
stashIndex,
operation,
hasConflicts: true,
conflictFiles,
});
return result;
return handleStashConflicts(worktreePath, stashIndex, operation, events);
}
// 5. Non-conflict git error re-throw so the outer catch logs and handles it
@@ -205,34 +218,7 @@ export async function applyOrPop(
events?.emit('stash:progress', { worktreePath, stashIndex, operation, output: combinedOutput });
if (isConflictOutput(combinedOutput)) {
const conflictFiles = await getConflictedFiles(worktreePath);
events?.emit('stash:conflicts', {
worktreePath,
stashIndex,
operation,
conflictFiles,
});
const result: StashApplyResult = {
success: true,
applied: true,
hasConflicts: true,
conflictFiles,
operation,
stashIndex,
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`,
};
events?.emit('stash:success', {
worktreePath,
stashIndex,
operation,
hasConflicts: true,
conflictFiles,
});
return result;
return handleStashConflicts(worktreePath, stashIndex, operation, events);
}
// 7. Clean success
@@ -296,17 +282,20 @@ export async function applyOrPop(
*/
export async function pushStash(
worktreePath: string,
options?: { message?: string; files?: string[] }
options?: { message?: string; files?: string[] },
events?: EventEmitter
): Promise<StashPushResult> {
const message = options?.message;
const files = options?.files;
logger.info(`[StashService] push stash in ${worktreePath}`);
events?.emit('stash:start', { worktreePath, operation: 'push' });
// 1. Check for any changes to stash
const status = await execGitCommand(['status', '--porcelain'], worktreePath);
if (!status.trim()) {
events?.emit('stash:success', { worktreePath, operation: 'push', stashed: false });
return {
success: true,
stashed: false,
@@ -326,13 +315,20 @@ export async function pushStash(
args.push(...files);
}
// 3. Execute stash push
await execGitCommand(args, worktreePath);
// 3. Execute stash push (with automatic index.lock cleanup and retry)
await execGitCommandWithLockRetry(args, worktreePath);
// 4. Get current branch name
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
const branchName = branchOutput.trim();
events?.emit('stash:success', {
worktreePath,
operation: 'push',
stashed: true,
branch: branchName,
});
return {
success: true,
stashed: true,
@@ -445,14 +441,18 @@ export async function listStash(worktreePath: string): Promise<StashListResult>
*/
export async function dropStash(
worktreePath: string,
stashIndex: number
stashIndex: number,
events?: EventEmitter
): Promise<StashDropResult> {
const stashRef = `stash@{${stashIndex}}`;
logger.info(`[StashService] drop ${stashRef} in ${worktreePath}`);
events?.emit('stash:start', { worktreePath, stashIndex, stashRef, operation: 'drop' });
await execGitCommand(['stash', 'drop', stashRef], worktreePath);
events?.emit('stash:success', { worktreePath, stashIndex, stashRef, operation: 'drop' });
return {
success: true,
dropped: true,

View File

@@ -16,9 +16,8 @@
* rebase-service.ts.
*/
import { createLogger } from '@automaker/utils';
import { execGitCommand } from '../lib/git.js';
import { getErrorMessage } from '../routes/worktree/common.js';
import { createLogger, getErrorMessage } from '@automaker/utils';
import { execGitCommand, execGitCommandWithLockRetry } from '../lib/git.js';
import type { EventEmitter } from '../lib/events.js';
const logger = createLogger('WorktreeBranchService');
@@ -66,7 +65,11 @@ async function hasAnyChanges(cwd: string): Promise<boolean> {
return true;
});
return lines.length > 0;
} catch {
} catch (err) {
logger.error('hasAnyChanges: execGitCommand failed — returning false', {
cwd,
error: getErrorMessage(err),
});
return false;
}
}
@@ -78,24 +81,11 @@ async function hasAnyChanges(cwd: string): Promise<boolean> {
*/
async function stashChanges(cwd: string, message: string): Promise<boolean> {
try {
// Get stash count before
const beforeOutput = await execGitCommand(['stash', 'list'], cwd);
const countBefore = beforeOutput
.trim()
.split('\n')
.filter((l) => l.trim()).length;
// Stash including untracked files
await execGitCommand(['stash', 'push', '--include-untracked', '-m', message], cwd);
// Get stash count after to verify something was stashed
const afterOutput = await execGitCommand(['stash', 'list'], cwd);
const countAfter = afterOutput
.trim()
.split('\n')
.filter((l) => l.trim()).length;
return countAfter > countBefore;
// Stash including untracked files — a successful execGitCommand is proof
// the stash was created. No need for a post-push listing which can throw
// and incorrectly report a failed stash.
await execGitCommandWithLockRetry(['stash', 'push', '--include-untracked', '-m', message], cwd);
return true;
} catch (error) {
const errorMsg = getErrorMessage(error);
@@ -127,11 +117,8 @@ async function popStash(
cwd: string
): Promise<{ success: boolean; hasConflicts: boolean; error?: string }> {
try {
const stdout = await execGitCommand(['stash', 'pop'], cwd);
// Check for conflict markers in the output
if (stdout.includes('CONFLICT') || stdout.includes('Merge conflict')) {
return { success: false, hasConflicts: true };
}
await execGitCommand(['stash', 'pop'], cwd);
// If execGitCommand succeeds (zero exit code), there are no conflicts
return { success: true, hasConflicts: false };
} catch (error) {
const errorMsg = getErrorMessage(error);
@@ -274,11 +261,9 @@ export async function performSwitchBranch(
};
}
// 4. Check if target branch exists (locally or as remote ref)
// 4. Check if target branch exists as a local branch
if (!isRemote) {
try {
await execGitCommand(['rev-parse', '--verify', branchName], worktreePath);
} catch {
if (!(await localBranchExists(worktreePath, branchName))) {
events?.emit('switch:error', {
worktreePath,
branchName,

View File

@@ -50,15 +50,15 @@ describe('sdk-options.ts', () => {
describe('getModelForUseCase', () => {
it('should return explicit model when provided', async () => {
const { getModelForUseCase } = await import('@/lib/sdk-options.js');
const result = getModelForUseCase('spec', 'claude-sonnet-4-20250514');
expect(result).toBe('claude-sonnet-4-20250514');
const result = getModelForUseCase('spec', 'claude-sonnet-4-6');
expect(result).toBe('claude-sonnet-4-6');
});
it('should use environment variable for spec model', async () => {
process.env.AUTOMAKER_MODEL_SPEC = 'claude-sonnet-4-20250514';
process.env.AUTOMAKER_MODEL_SPEC = 'claude-sonnet-4-6';
const { getModelForUseCase } = await import('@/lib/sdk-options.js');
const result = getModelForUseCase('spec');
expect(result).toBe('claude-sonnet-4-20250514');
expect(result).toBe('claude-sonnet-4-6');
});
it('should use default model for spec when no override', async () => {
@@ -71,10 +71,10 @@ describe('sdk-options.ts', () => {
it('should fall back to AUTOMAKER_MODEL_DEFAULT', async () => {
delete process.env.AUTOMAKER_MODEL_SPEC;
process.env.AUTOMAKER_MODEL_DEFAULT = 'claude-sonnet-4-20250514';
process.env.AUTOMAKER_MODEL_DEFAULT = 'claude-sonnet-4-6';
const { getModelForUseCase } = await import('@/lib/sdk-options.js');
const result = getModelForUseCase('spec');
expect(result).toBe('claude-sonnet-4-20250514');
expect(result).toBe('claude-sonnet-4-6');
});
});
@@ -203,10 +203,10 @@ describe('sdk-options.ts', () => {
const options = createChatOptions({
cwd: '/test/path',
sessionModel: 'claude-sonnet-4-20250514',
sessionModel: 'claude-sonnet-4-6',
});
expect(options.model).toBe('claude-sonnet-4-20250514');
expect(options.model).toBe('claude-sonnet-4-6');
});
});

View File

@@ -360,10 +360,10 @@ describe('claude-provider.ts', () => {
});
describe('getAvailableModels', () => {
it('should return 4 Claude models', () => {
it('should return 5 Claude models', () => {
const models = provider.getAvailableModels();
expect(models).toHaveLength(4);
expect(models).toHaveLength(5);
});
it('should include Claude Opus 4.6', () => {
@@ -375,12 +375,12 @@ describe('claude-provider.ts', () => {
expect(opus?.provider).toBe('anthropic');
});
it('should include Claude Sonnet 4', () => {
it('should include Claude Sonnet 4.6', () => {
const models = provider.getAvailableModels();
const sonnet = models.find((m) => m.id === 'claude-sonnet-4-20250514');
const sonnet = models.find((m) => m.id === 'claude-sonnet-4-6');
expect(sonnet).toBeDefined();
expect(sonnet?.name).toBe('Claude Sonnet 4');
expect(sonnet?.name).toBe('Claude Sonnet 4.6');
});
it('should include Claude 3.5 Sonnet', () => {

View File

@@ -69,19 +69,19 @@ describe('opencode-provider.ts', () => {
it('should include free tier GLM model', () => {
const models = provider.getAvailableModels();
const glm = models.find((m) => m.id === 'opencode/glm-4.7-free');
const glm = models.find((m) => m.id === 'opencode/glm-5-free');
expect(glm).toBeDefined();
expect(glm?.name).toBe('GLM 4.7 Free');
expect(glm?.name).toBe('GLM 5 Free');
expect(glm?.tier).toBe('basic');
});
it('should include free tier MiniMax model', () => {
const models = provider.getAvailableModels();
const minimax = models.find((m) => m.id === 'opencode/minimax-m2.1-free');
const minimax = models.find((m) => m.id === 'opencode/minimax-m2.5-free');
expect(minimax).toBeDefined();
expect(minimax?.name).toBe('MiniMax M2.1 Free');
expect(minimax?.name).toBe('MiniMax M2.5 Free');
expect(minimax?.tier).toBe('basic');
});

View File

@@ -59,8 +59,8 @@ describe('provider-factory.ts', () => {
expect(provider).toBeInstanceOf(ClaudeProvider);
});
it('should return ClaudeProvider for claude-sonnet-4-20250514', () => {
const provider = ProviderFactory.getProviderForModel('claude-sonnet-4-20250514');
it('should return ClaudeProvider for claude-sonnet-4-6', () => {
const provider = ProviderFactory.getProviderForModel('claude-sonnet-4-6');
expect(provider).toBeInstanceOf(ClaudeProvider);
});

View File

@@ -129,7 +129,7 @@ describe('AgentExecutor', () => {
projectPath: '/project',
abortController: new AbortController(),
provider: {} as BaseProvider,
effectiveBareModel: 'claude-sonnet-4-20250514',
effectiveBareModel: 'claude-sonnet-4-6',
};
expect(options.featureId).toBe('test-feature');
});
@@ -166,7 +166,7 @@ describe('AgentExecutor', () => {
projectPath: '/test/project',
abortController: new AbortController(),
provider: {} as BaseProvider,
effectiveBareModel: 'claude-sonnet-4-20250514',
effectiveBareModel: 'claude-sonnet-4-6',
};
expect(options.workDir).toBe('/test/workdir');
@@ -174,7 +174,7 @@ describe('AgentExecutor', () => {
expect(options.prompt).toBe('Test prompt');
expect(options.projectPath).toBe('/test/project');
expect(options.abortController).toBeInstanceOf(AbortController);
expect(options.effectiveBareModel).toBe('claude-sonnet-4-20250514');
expect(options.effectiveBareModel).toBe('claude-sonnet-4-6');
});
it('should accept optional options', () => {
@@ -185,10 +185,10 @@ describe('AgentExecutor', () => {
projectPath: '/test/project',
abortController: new AbortController(),
provider: {} as BaseProvider,
effectiveBareModel: 'claude-sonnet-4-20250514',
effectiveBareModel: 'claude-sonnet-4-6',
// Optional fields
imagePaths: ['/image1.png', '/image2.png'],
model: 'claude-sonnet-4-20250514',
model: 'claude-sonnet-4-6',
planningMode: 'spec',
requirePlanApproval: true,
previousContent: 'Previous content',
@@ -419,7 +419,7 @@ describe('AgentExecutor', () => {
projectPath: '/project',
abortController,
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-20250514',
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
};
@@ -461,7 +461,7 @@ describe('AgentExecutor', () => {
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-20250514',
effectiveBareModel: 'claude-sonnet-4-6',
previousContent: 'Previous context from earlier session',
};
@@ -507,7 +507,7 @@ describe('AgentExecutor', () => {
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-20250514',
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip', // No spec detection in skip mode
};
@@ -558,7 +558,7 @@ describe('AgentExecutor', () => {
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-20250514',
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
};
@@ -618,7 +618,7 @@ describe('AgentExecutor', () => {
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-20250514',
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
};
@@ -671,7 +671,7 @@ describe('AgentExecutor', () => {
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-20250514',
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
};
@@ -712,7 +712,7 @@ describe('AgentExecutor', () => {
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-20250514',
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
};
@@ -763,7 +763,7 @@ describe('AgentExecutor', () => {
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-20250514',
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
};
@@ -810,7 +810,7 @@ describe('AgentExecutor', () => {
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-20250514',
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
};
@@ -855,7 +855,7 @@ describe('AgentExecutor', () => {
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-20250514',
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
branchName: 'feature/my-feature',
};
@@ -906,7 +906,7 @@ describe('AgentExecutor', () => {
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet-4-20250514',
effectiveBareModel: 'claude-sonnet-4-6',
planningMode: 'skip',
};

View File

@@ -272,10 +272,10 @@ describe('agent-service.ts', () => {
await service.sendMessage({
sessionId: 'session-1',
message: 'Hello',
model: 'claude-sonnet-4-20250514',
model: 'claude-sonnet-4-6',
});
expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith('claude-sonnet-4-20250514');
expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith('claude-sonnet-4-6');
});
it('should save session messages', async () => {
@@ -339,7 +339,10 @@ describe('agent-service.ts', () => {
it('should handle non-existent session', async () => {
const history = await service.getHistory('nonexistent');
expect(history).toBeDefined(); // Returns error object
expect(history).toBeDefined();
expect(history.success).toBe(false);
expect(history.error).toBeDefined();
expect(typeof history.error).toBe('string');
});
});
@@ -530,13 +533,13 @@ describe('agent-service.ts', () => {
it('should set model for existing session', async () => {
vi.mocked(fs.readFile).mockResolvedValue('{"session-1": {}}');
const result = await service.setSessionModel('session-1', 'claude-sonnet-4-20250514');
const result = await service.setSessionModel('session-1', 'claude-sonnet-4-6');
expect(result).toBe(true);
});
it('should return false for non-existent session', async () => {
const result = await service.setSessionModel('nonexistent', 'claude-sonnet-4-20250514');
const result = await service.setSessionModel('nonexistent', 'claude-sonnet-4-6');
expect(result).toBe(false);
});
@@ -719,7 +722,7 @@ describe('agent-service.ts', () => {
const result = await service.addToQueue('session-1', {
message: 'Test prompt',
imagePaths: ['/test/image.png'],
model: 'claude-sonnet-4-20250514',
model: 'claude-sonnet-4-6',
});
expect(result.success).toBe(true);

View File

@@ -25,7 +25,7 @@ const mockLogger = vi.hoisted(() => ({
const mockCreateChatOptions = vi.hoisted(() =>
vi.fn(() => ({
model: 'claude-sonnet-4-20250514',
model: 'claude-sonnet-4-6',
systemPrompt: 'test prompt',
}))
);

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useCallback } from 'react';
import { cn } from '@/lib/utils';
import {
File,
@@ -11,11 +11,15 @@ import {
RefreshCw,
GitBranch,
AlertCircle,
Plus,
Minus,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
import { Button } from './button';
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import type { FileStatus } from '@/types/electron';
interface GitDiffPanelProps {
@@ -26,6 +30,10 @@ interface GitDiffPanelProps {
compact?: boolean;
/** Whether worktrees are enabled - if false, shows diffs from main project */
useWorktrees?: boolean;
/** Whether to show stage/unstage controls for each file */
enableStaging?: boolean;
/** The worktree path to use for staging operations (required when enableStaging is true) */
worktreePath?: string;
}
interface ParsedDiffHunk {
@@ -102,6 +110,24 @@ const getStatusDisplayName = (status: string) => {
}
};
/**
* Determine the staging state of a file based on its indexStatus and workTreeStatus
*/
function getStagingState(file: FileStatus): 'staged' | 'unstaged' | 'partial' {
const idx = file.indexStatus ?? ' ';
const wt = file.workTreeStatus ?? ' ';
// Untracked files
if (idx === '?' && wt === '?') return 'unstaged';
const hasIndexChanges = idx !== ' ' && idx !== '?';
const hasWorkTreeChanges = wt !== ' ' && wt !== '?';
if (hasIndexChanges && hasWorkTreeChanges) return 'partial';
if (hasIndexChanges) return 'staged';
return 'unstaged';
}
/**
* Parse unified diff format into structured data
*/
@@ -270,14 +296,46 @@ function DiffLine({
);
}
function StagingBadge({ state }: { state: 'staged' | 'unstaged' | 'partial' }) {
if (state === 'staged') {
return (
<span className="text-[10px] px-1.5 py-0.5 rounded border font-medium bg-green-500/15 text-green-400 border-green-500/30">
Staged
</span>
);
}
if (state === 'partial') {
return (
<span className="text-[10px] px-1.5 py-0.5 rounded border font-medium bg-amber-500/15 text-amber-400 border-amber-500/30">
Partial
</span>
);
}
return (
<span className="text-[10px] px-1.5 py-0.5 rounded border font-medium bg-muted text-muted-foreground border-border">
Unstaged
</span>
);
}
function FileDiffSection({
fileDiff,
isExpanded,
onToggle,
fileStatus,
enableStaging,
onStage,
onUnstage,
isStagingFile,
}: {
fileDiff: ParsedFileDiff;
isExpanded: boolean;
onToggle: () => void;
fileStatus?: FileStatus;
enableStaging?: boolean;
onStage?: (filePath: string) => void;
onUnstage?: (filePath: string) => void;
isStagingFile?: boolean;
}) {
const additions = fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'addition').length,
@@ -288,23 +346,29 @@ function FileDiffSection({
0
);
const stagingState = fileStatus ? getStagingState(fileStatus) : undefined;
return (
<div className="border border-border rounded-lg overflow-hidden">
<button
onClick={onToggle}
className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card hover:bg-accent/50 transition-colors"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<TruncatedFilePath
path={fileDiff.filePath}
className="flex-1 text-sm font-mono text-foreground"
/>
<div className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card hover:bg-accent/50 transition-colors">
<button onClick={onToggle} className="flex items-center gap-2 flex-1 min-w-0 text-left">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
{fileStatus ? (
getFileIcon(fileStatus.status)
) : (
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
<TruncatedFilePath
path={fileDiff.filePath}
className="flex-1 text-sm font-mono text-foreground"
/>
</button>
<div className="flex items-center gap-2 flex-shrink-0">
{enableStaging && stagingState && <StagingBadge state={stagingState} />}
{fileDiff.isNew && (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
new
@@ -322,8 +386,43 @@ function FileDiffSection({
)}
{additions > 0 && <span className="text-xs text-green-400">+{additions}</span>}
{deletions > 0 && <span className="text-xs text-red-400">-{deletions}</span>}
{enableStaging && onStage && onUnstage && (
<div className="flex items-center gap-1 ml-1">
{isStagingFile ? (
<Spinner size="sm" />
) : stagingState === 'staged' || stagingState === 'partial' ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={(e) => {
e.stopPropagation();
onUnstage(fileDiff.filePath);
}}
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={(e) => {
e.stopPropagation();
onStage(fileDiff.filePath);
}}
title="Stage file"
>
<Plus className="w-3 h-3 mr-1" />
Stage
</Button>
)}
</div>
)}
</div>
</button>
</div>
{isExpanded && (
<div className="bg-background border-t border-border max-h-[400px] overflow-y-auto scrollbar-visible">
{fileDiff.hunks.map((hunk, hunkIndex) => (
@@ -350,9 +449,12 @@ export function GitDiffPanel({
className,
compact = true,
useWorktrees = false,
enableStaging = false,
worktreePath,
}: GitDiffPanelProps) {
const [isExpanded, setIsExpanded] = useState(!compact);
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const [stagingInProgress, setStagingInProgress] = useState<Set<string>>(new Set());
// Use worktree diffs hook when worktrees are enabled and panel is expanded
// Pass undefined for featureId when not using worktrees to disable the query
@@ -393,6 +495,15 @@ export function GitDiffPanel({
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
// Build a map from file path to FileStatus for quick lookup
const fileStatusMap = useMemo(() => {
const map = new Map<string, FileStatus>();
for (const file of files) {
map.set(file.path, file);
}
return map;
}, [files]);
const toggleFile = (filePath: string) => {
setExpandedFiles((prev) => {
const next = new Set(prev);
@@ -413,6 +524,224 @@ export function GitDiffPanel({
setExpandedFiles(new Set());
};
// Stage/unstage a single file
const handleStageFile = useCallback(
async (filePath: string) => {
if (!worktreePath && !projectPath) return;
setStagingInProgress((prev) => new Set(prev).add(filePath));
try {
const api = getElectronAPI();
let result: { success: boolean; error?: string } | undefined;
if (useWorktrees && worktreePath) {
if (!api.worktree?.stageFiles) {
toast.error('Failed to stage file', {
description: 'Worktree stage API not available',
});
return;
}
result = await api.worktree.stageFiles(worktreePath, [filePath], 'stage');
} else if (!useWorktrees) {
if (!api.git?.stageFiles) {
toast.error('Failed to stage file', { description: 'Git stage API not available' });
return;
}
result = await api.git.stageFiles(projectPath, [filePath], 'stage');
}
if (!result) {
toast.error('Failed to stage file', { description: 'Stage API not available' });
return;
}
if (!result.success) {
toast.error('Failed to stage file', { description: result.error });
return;
}
// Refetch diffs to reflect the new staging state
await loadDiffs();
toast.success('File staged', { description: filePath });
} catch (err) {
toast.error('Failed to stage file', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setStagingInProgress((prev) => {
const next = new Set(prev);
next.delete(filePath);
return next;
});
}
},
[worktreePath, projectPath, useWorktrees, loadDiffs]
);
// Unstage a single file
const handleUnstageFile = useCallback(
async (filePath: string) => {
if (!worktreePath && !projectPath) return;
setStagingInProgress((prev) => new Set(prev).add(filePath));
try {
const api = getElectronAPI();
let result: { success: boolean; error?: string } | undefined;
if (useWorktrees && worktreePath) {
if (!api.worktree?.stageFiles) {
toast.error('Failed to unstage file', {
description: 'Worktree stage API not available',
});
return;
}
result = await api.worktree.stageFiles(worktreePath, [filePath], 'unstage');
} else if (!useWorktrees) {
if (!api.git?.stageFiles) {
toast.error('Failed to unstage file', { description: 'Git stage API not available' });
return;
}
result = await api.git.stageFiles(projectPath, [filePath], 'unstage');
}
if (!result) {
toast.error('Failed to unstage file', { description: 'Stage API not available' });
return;
}
if (!result.success) {
toast.error('Failed to unstage file', { description: result.error });
return;
}
// Refetch diffs to reflect the new staging state
await loadDiffs();
toast.success('File unstaged', { description: filePath });
} catch (err) {
toast.error('Failed to unstage file', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setStagingInProgress((prev) => {
const next = new Set(prev);
next.delete(filePath);
return next;
});
}
},
[worktreePath, projectPath, useWorktrees, loadDiffs]
);
const handleStageAll = useCallback(async () => {
if (!worktreePath && !projectPath) return;
const allPaths = files.map((f) => f.path);
if (allPaths.length === 0) return;
setStagingInProgress(new Set(allPaths));
try {
const api = getElectronAPI();
let result: { success: boolean; error?: string } | undefined;
if (useWorktrees && worktreePath) {
if (!api.worktree?.stageFiles) {
toast.error('Failed to stage all files', {
description: 'Worktree stage API not available',
});
return;
}
result = await api.worktree.stageFiles(worktreePath, allPaths, 'stage');
} else if (!useWorktrees) {
if (!api.git?.stageFiles) {
toast.error('Failed to stage all files', { description: 'Git stage API not available' });
return;
}
result = await api.git.stageFiles(projectPath, allPaths, 'stage');
}
if (!result) {
toast.error('Failed to stage all files', { description: 'Stage API not available' });
return;
}
if (!result.success) {
toast.error('Failed to stage all files', { description: result.error });
return;
}
await loadDiffs();
toast.success('All files staged');
} catch (err) {
toast.error('Failed to stage all files', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setStagingInProgress(new Set());
}
}, [worktreePath, projectPath, useWorktrees, files, loadDiffs]);
const handleUnstageAll = useCallback(async () => {
if (!worktreePath && !projectPath) return;
const allPaths = files.map((f) => f.path);
if (allPaths.length === 0) return;
setStagingInProgress(new Set(allPaths));
try {
const api = getElectronAPI();
let result: { success: boolean; error?: string } | undefined;
if (useWorktrees && worktreePath) {
if (!api.worktree?.stageFiles) {
toast.error('Failed to unstage all files', {
description: 'Worktree stage API not available',
});
return;
}
result = await api.worktree.stageFiles(worktreePath, allPaths, 'unstage');
} else if (!useWorktrees) {
if (!api.git?.stageFiles) {
toast.error('Failed to unstage all files', {
description: 'Git stage API not available',
});
return;
}
result = await api.git.stageFiles(projectPath, allPaths, 'unstage');
}
if (!result) {
toast.error('Failed to unstage all files', { description: 'Stage API not available' });
return;
}
if (!result.success) {
toast.error('Failed to unstage all files', { description: result.error });
return;
}
await loadDiffs();
toast.success('All files unstaged');
} catch (err) {
toast.error('Failed to unstage all files', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setStagingInProgress(new Set());
}
}, [worktreePath, projectPath, useWorktrees, files, loadDiffs]);
// Compute staging summary
const stagingSummary = useMemo(() => {
if (!enableStaging) return null;
let staged = 0;
let unstaged = 0;
for (const file of files) {
const state = getStagingState(file);
if (state === 'staged') staged++;
else if (state === 'unstaged') unstaged++;
else {
// partial counts as both
staged++;
unstaged++;
}
}
return { staged, unstaged, total: files.length };
}, [enableStaging, files]);
// Total stats
const totalAdditions = parsedDiffs.reduce(
(acc, file) =>
@@ -536,6 +865,30 @@ export function GitDiffPanel({
})()}
</div>
<div className="flex items-center gap-2">
{enableStaging && stagingSummary && (
<>
<Button
variant="ghost"
size="sm"
onClick={handleStageAll}
className="text-xs h-7"
disabled={stagingInProgress.size > 0 || stagingSummary.unstaged === 0}
>
<Plus className="w-3 h-3 mr-1" />
Stage All
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleUnstageAll}
className="text-xs h-7"
disabled={stagingInProgress.size > 0 || stagingSummary.staged === 0}
>
<Minus className="w-3 h-3 mr-1" />
Unstage All
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
@@ -575,6 +928,11 @@ export function GitDiffPanel({
{totalDeletions > 0 && (
<span className="text-red-400">-{totalDeletions} deletions</span>
)}
{enableStaging && stagingSummary && (
<span className="text-muted-foreground">
({stagingSummary.staged} staged, {stagingSummary.unstaged} unstaged)
</span>
)}
</div>
</div>
@@ -586,6 +944,11 @@ export function GitDiffPanel({
fileDiff={fileDiff}
isExpanded={expandedFiles.has(fileDiff.filePath)}
onToggle={() => toggleFile(fileDiff.filePath)}
fileStatus={enableStaging ? fileStatusMap.get(fileDiff.filePath) : undefined}
enableStaging={enableStaging}
onStage={enableStaging ? handleStageFile : undefined}
onUnstage={enableStaging ? handleUnstageFile : undefined}
isStagingFile={stagingInProgress.has(fileDiff.filePath)}
/>
))}
{/* Fallback for files that have no diff content (shouldn't happen after fix, but safety net) */}
@@ -602,6 +965,7 @@ export function GitDiffPanel({
path={file.path}
className="flex-1 text-sm font-mono text-foreground"
/>
{enableStaging && <StagingBadge state={getStagingState(file)} />}
<span
className={cn(
'text-xs px-1.5 py-0.5 rounded border font-medium',
@@ -610,6 +974,36 @@ export function GitDiffPanel({
>
{getStatusDisplayName(file.status)}
</span>
{enableStaging && (
<div className="flex items-center gap-1 ml-1">
{stagingInProgress.has(file.path) ? (
<Spinner size="sm" />
) : getStagingState(file) === 'staged' ||
getStagingState(file) === '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>
)}
</div>
<div className="px-4 py-3 text-sm text-muted-foreground bg-background border-t border-border">
{file.status === '?' ? (

View File

@@ -56,7 +56,7 @@ import {
PlanApprovalDialog,
MergeRebaseDialog,
} from './board-view/dialogs';
import type { DependencyLinkType, PullStrategy } from './board-view/dialogs';
import type { DependencyLinkType } from './board-view/dialogs';
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
@@ -87,7 +87,8 @@ import {
useListViewState,
} from './board-view/hooks';
import { SelectionActionBar, ListView } from './board-view/components';
import { MassEditDialog } from './board-view/dialogs';
import { MassEditDialog, BranchConflictDialog } from './board-view/dialogs';
import type { BranchConflictData } from './board-view/dialogs';
import { InitScriptIndicator } from './board-view/init-script-indicator';
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
import { usePipelineConfig } from '@/hooks/queries';
@@ -189,6 +190,10 @@ export function BoardView() {
);
const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0);
// Branch conflict dialog state (for branch switch and stash pop conflicts)
const [branchConflictData, setBranchConflictData] = useState<BranchConflictData | null>(null);
const [showBranchConflictDialog, setShowBranchConflictDialog] = useState(false);
// Backlog plan dialog state
const [showPlanDialog, setShowPlanDialog] = useState(false);
const [pendingBacklogPlan, setPendingBacklogPlan] = useState<BacklogPlanResult | null>(null);
@@ -935,56 +940,29 @@ export function BoardView() {
setShowMergeRebaseDialog(true);
}, []);
// Handler called when user confirms the merge & rebase dialog
const handleConfirmResolveConflicts = useCallback(
async (worktree: WorktreeInfo, remoteBranch: string, strategy: PullStrategy) => {
const isRebase = strategy === 'rebase';
const description = isRebase
? `Fetch the latest changes from ${remoteBranch} and rebase the current branch (${worktree.branch}) onto ${remoteBranch}. Use "git fetch" followed by "git rebase ${remoteBranch}" to replay commits on top of the remote branch for a linear history. If rebase conflicts arise, resolve them one commit at a time using "git rebase --continue" after fixing each conflict. After completing the rebase, ensure the code compiles and tests pass.`
: `Pull latest from ${remoteBranch} and resolve conflicts. Merge ${remoteBranch} into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`;
const title = isRebase
? `Rebase & Resolve Conflicts: ${worktree.branch} onto ${remoteBranch}`
: `Resolve Merge Conflicts: ${remoteBranch}${worktree.branch}`;
const featureData = {
title,
category: 'Maintenance',
description,
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: 'opus' as const,
thinkingLevel: 'none' as const,
branchName: worktree.branch,
workMode: 'custom' as const, // Use the worktree's branch
priority: 1, // High priority for conflict resolution
planningMode: 'skip' as const,
requirePlanApproval: false,
};
await handleAddAndStartFeature(featureData);
},
[handleAddAndStartFeature, defaultSkipTests]
);
// Handler called when merge/rebase fails due to conflicts and user wants to create a feature to resolve them
const handleCreateMergeConflictResolutionFeature = useCallback(
async (conflictInfo: MergeConflictInfo) => {
const isRebase = conflictInfo.operationType === 'rebase';
const isCherryPick = conflictInfo.operationType === 'cherry-pick';
const conflictFilesInfo =
conflictInfo.conflictFiles && conflictInfo.conflictFiles.length > 0
? `\n\nConflicting files:\n${conflictInfo.conflictFiles.map((f) => `- ${f}`).join('\n')}`
: '';
const description = isRebase
? `Fetch the latest changes from ${conflictInfo.sourceBranch} and rebase the current branch (${conflictInfo.targetBranch}) onto ${conflictInfo.sourceBranch}. Use "git fetch" followed by "git rebase ${conflictInfo.sourceBranch}" to replay commits on top of the remote branch for a linear history. If rebase conflicts arise, resolve them one commit at a time using "git rebase --continue" after fixing each conflict. After completing the rebase, ensure the code compiles and tests pass.${conflictFilesInfo}`
: `Resolve merge conflicts when merging "${conflictInfo.sourceBranch}" into "${conflictInfo.targetBranch}". The merge was started but encountered conflicts that need to be resolved manually. After resolving all conflicts, ensure the code compiles and tests pass, then complete the merge by committing the resolved changes.${conflictFilesInfo}`;
let description: string;
let title: string;
const title = isRebase
? `Rebase & Resolve Conflicts: ${conflictInfo.targetBranch} onto ${conflictInfo.sourceBranch}`
: `Resolve Merge Conflicts: ${conflictInfo.sourceBranch} ${conflictInfo.targetBranch}`;
if (isRebase) {
description = `Fetch the latest changes from ${conflictInfo.sourceBranch} and rebase the current branch (${conflictInfo.targetBranch}) onto ${conflictInfo.sourceBranch}. Use "git fetch" followed by "git rebase ${conflictInfo.sourceBranch}" to replay commits on top of the remote branch for a linear history. If rebase conflicts arise, resolve them one commit at a time using "git rebase --continue" after fixing each conflict. After completing the rebase, ensure the code compiles and tests pass.${conflictFilesInfo}`;
title = `Rebase & Resolve Conflicts: ${conflictInfo.targetBranch} onto ${conflictInfo.sourceBranch}`;
} else if (isCherryPick) {
description = `Resolve cherry-pick conflicts when cherry-picking commits from "${conflictInfo.sourceBranch}" into "${conflictInfo.targetBranch}". The cherry-pick was attempted but encountered conflicts that need to be resolved manually. Cherry-pick the commits again using "git cherry-pick <commit-hashes>", resolve any conflicts, then use "git cherry-pick --continue" after fixing each conflict. After completing the cherry-pick, ensure the code compiles and tests pass.${conflictFilesInfo}`;
title = `Resolve Cherry-Pick Conflicts: ${conflictInfo.sourceBranch}${conflictInfo.targetBranch}`;
} else {
description = `Resolve merge conflicts when merging "${conflictInfo.sourceBranch}" into "${conflictInfo.targetBranch}". The merge was started but encountered conflicts that need to be resolved manually. After resolving all conflicts, ensure the code compiles and tests pass, then complete the merge by committing the resolved changes.${conflictFilesInfo}`;
title = `Resolve Merge Conflicts: ${conflictInfo.sourceBranch}${conflictInfo.targetBranch}`;
}
const featureData = {
title,
@@ -1007,60 +985,72 @@ export function BoardView() {
[handleAddAndStartFeature, defaultSkipTests]
);
// Handler called when branch switch stash reapply causes merge conflicts
const handleBranchSwitchConflict = useCallback(
async (conflictInfo: BranchSwitchConflictInfo) => {
const description = `Resolve merge conflicts that occurred when switching from "${conflictInfo.previousBranch}" to "${conflictInfo.branchName}". Local changes were stashed before switching and reapplying them caused conflicts. Please resolve all merge conflicts, ensure the code compiles and tests pass.`;
const featureData = {
title: `Resolve Stash Conflicts: switch to ${conflictInfo.branchName}`,
category: 'Maintenance',
description,
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: resolveModelString('opus'),
thinkingLevel: 'none' as const,
branchName: conflictInfo.branchName,
workMode: 'custom' as const,
priority: 1,
planningMode: 'skip' as const,
requirePlanApproval: false,
};
await handleAddAndStartFeature(featureData);
},
[handleAddAndStartFeature, defaultSkipTests]
);
// Handler called when branch switch stash reapply causes merge conflicts.
// Shows a dialog to let the user choose between manual or AI resolution.
const handleBranchSwitchConflict = useCallback((conflictInfo: BranchSwitchConflictInfo) => {
setBranchConflictData({ type: 'branch-switch', info: conflictInfo });
setShowBranchConflictDialog(true);
}, []);
// Handler called when checkout fails AND the stash-pop restoration produces merge conflicts.
// Creates an AI-assisted board task to guide the user through resolving the conflicts.
const handleStashPopConflict = useCallback(
async (conflictInfo: StashPopConflictInfo) => {
const description =
`Resolve merge conflicts that occurred when attempting to switch to branch "${conflictInfo.branchName}". ` +
`The checkout failed and, while restoring the previously stashed local changes, git reported merge conflicts. ` +
`${conflictInfo.stashPopConflictMessage} ` +
`Please review all conflicted files, resolve the conflicts, ensure the code compiles and tests pass, ` +
`then re-attempt the branch switch.`;
// Shows a dialog to let the user choose between manual or AI resolution.
const handleStashPopConflict = useCallback((conflictInfo: StashPopConflictInfo) => {
setBranchConflictData({ type: 'stash-pop', info: conflictInfo });
setShowBranchConflictDialog(true);
}, []);
const featureData = {
title: `Resolve Stash-Pop Conflicts: branch switch to ${conflictInfo.branchName}`,
category: 'Maintenance',
description,
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: resolveModelString('opus'),
thinkingLevel: 'none' as const,
branchName: conflictInfo.branchName,
workMode: 'custom' as const,
priority: 1,
planningMode: 'skip' as const,
requirePlanApproval: false,
};
// Handler called when the user selects "Resolve with AI" from the branch conflict dialog.
// Creates and starts the AI-assisted conflict resolution feature task.
const handleBranchConflictResolveWithAI = useCallback(
async (conflictData: BranchConflictData) => {
if (conflictData.type === 'branch-switch') {
const conflictInfo = conflictData.info;
const description = `Resolve merge conflicts that occurred when switching from "${conflictInfo.previousBranch}" to "${conflictInfo.branchName}". Local changes were stashed before switching and reapplying them caused conflicts. Please resolve all merge conflicts, ensure the code compiles and tests pass.`;
await handleAddAndStartFeature(featureData);
const featureData = {
title: `Resolve Stash Conflicts: switch to ${conflictInfo.branchName}`,
category: 'Maintenance',
description,
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: resolveModelString('opus'),
thinkingLevel: 'none' as const,
branchName: conflictInfo.branchName,
workMode: 'custom' as const,
priority: 1,
planningMode: 'skip' as const,
requirePlanApproval: false,
};
await handleAddAndStartFeature(featureData);
} else {
const conflictInfo = conflictData.info;
const description =
`Resolve merge conflicts that occurred when attempting to switch to branch "${conflictInfo.branchName}". ` +
`The checkout failed and, while restoring the previously stashed local changes, git reported merge conflicts. ` +
`${conflictInfo.stashPopConflictMessage} ` +
`Please review all conflicted files, resolve the conflicts, ensure the code compiles and tests pass, ` +
`then re-attempt the branch switch.`;
const featureData = {
title: `Resolve Stash-Pop Conflicts: branch switch to ${conflictInfo.branchName}`,
category: 'Maintenance',
description,
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: resolveModelString('opus'),
thinkingLevel: 'none' as const,
branchName: conflictInfo.branchName,
workMode: 'custom' as const,
priority: 1,
planningMode: 'skip' as const,
requirePlanApproval: false,
};
await handleAddAndStartFeature(featureData);
}
},
[handleAddAndStartFeature, defaultSkipTests]
);
@@ -1925,10 +1915,17 @@ export function BoardView() {
open={showMergeRebaseDialog}
onOpenChange={setShowMergeRebaseDialog}
worktree={selectedWorktreeForAction}
onConfirm={handleConfirmResolveConflicts}
onCreateConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
/>
{/* Branch Switch / Stash Pop Conflict Dialog */}
<BranchConflictDialog
open={showBranchConflictDialog}
onOpenChange={setShowBranchConflictDialog}
conflictData={branchConflictData}
onResolveWithAI={handleBranchConflictResolveWithAI}
/>
{/* Commit Worktree Dialog */}
<CommitWorktreeDialog
open={showCommitWorktreeDialog}

View File

@@ -0,0 +1,143 @@
/**
* Dialog shown when a branch switch or stash-pop operation results in merge conflicts.
* Presents the user with two options:
* 1. Resolve Manually - leaves conflict markers in place
* 2. Resolve with AI - creates a feature task for AI-powered conflict resolution
*
* This dialog ensures the user can choose how to handle the conflict instead of
* automatically creating and starting an AI task.
*/
import { useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertTriangle, Wrench, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import type { BranchSwitchConflictInfo, StashPopConflictInfo } from '../worktree-panel/types';
export type BranchConflictType = 'branch-switch' | 'stash-pop';
export type BranchConflictData =
| { type: 'branch-switch'; info: BranchSwitchConflictInfo }
| { type: 'stash-pop'; info: StashPopConflictInfo };
interface BranchConflictDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
conflictData: BranchConflictData | null;
onResolveWithAI?: (conflictData: BranchConflictData) => void;
}
export function BranchConflictDialog({
open,
onOpenChange,
conflictData,
onResolveWithAI,
}: BranchConflictDialogProps) {
const handleResolveManually = useCallback(() => {
toast.info('Conflict markers left in place', {
description: 'Edit the conflicting files to resolve conflicts manually.',
duration: 6000,
});
onOpenChange(false);
}, [onOpenChange]);
const handleResolveWithAI = useCallback(() => {
if (!conflictData || !onResolveWithAI) return;
onResolveWithAI(conflictData);
onOpenChange(false);
}, [conflictData, onResolveWithAI, onOpenChange]);
if (!conflictData) return null;
const isBranchSwitch = conflictData.type === 'branch-switch';
const branchName = isBranchSwitch ? conflictData.info.branchName : conflictData.info.branchName;
const description = isBranchSwitch ? (
<>
Merge conflicts occurred when switching from{' '}
<code className="font-mono bg-muted px-1 rounded">
{(conflictData.info as BranchSwitchConflictInfo).previousBranch}
</code>{' '}
to <code className="font-mono bg-muted px-1 rounded">{branchName}</code>. Local changes were
stashed before switching and reapplying them caused conflicts.
</>
) : (
<>
The branch switch to <code className="font-mono bg-muted px-1 rounded">{branchName}</code>{' '}
failed and restoring the previously stashed local changes resulted in merge conflicts.
</>
);
const title = isBranchSwitch
? 'Branch Switch Conflicts Detected'
: 'Stash Restore Conflicts Detected';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
{title}
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<span className="block">{description}</span>
{!isBranchSwitch &&
(conflictData.info as StashPopConflictInfo).stashPopConflictMessage && (
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20">
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
<span className="text-orange-500 text-sm">
{(conflictData.info as StashPopConflictInfo).stashPopConflictMessage}
</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 resolve:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
<strong>Resolve with AI</strong> &mdash; Creates a task to analyze and resolve
conflicts automatically
</li>
<li>
<strong>Resolve Manually</strong> &mdash; Leaves conflict markers in place for
you to edit directly
</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleResolveManually}>
<Wrench className="w-4 h-4 mr-2" />
Resolve Manually
</Button>
{onResolveWithAI && (
<Button
onClick={handleResolveWithAI}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<Sparkles className="w-4 h-4 mr-2" />
Resolve with AI
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -411,7 +411,7 @@ export function CherryPickDialog({
sourceBranch: selectedBranch,
targetBranch: conflictInfo.targetBranch,
targetWorktreePath: conflictInfo.targetWorktreePath,
operationType: 'merge',
operationType: 'cherry-pick',
});
onOpenChange(false);
}
@@ -461,7 +461,7 @@ export function CherryPickDialog({
Cherry-pick the selected commit(s) from{' '}
<code className="font-mono bg-muted px-0.5 rounded">{selectedBranch}</code>
</li>
<li>Resolve any merge conflicts</li>
<li>Resolve any cherry-pick conflicts</li>
<li>Ensure the code compiles and tests pass</li>
</ul>
</div>

View File

@@ -29,6 +29,7 @@ import { useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
import type { FileStatus } from '@/types/electron';
import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
interface WorktreeInfo {
path: string;
@@ -45,23 +46,6 @@ interface CommitWorktreeDialogProps {
onCommitted: () => void;
}
interface ParsedDiffHunk {
header: string;
lines: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}[];
}
interface ParsedFileDiff {
filePath: string;
hunks: ParsedDiffHunk[];
isNew?: boolean;
isDeleted?: boolean;
isRenamed?: boolean;
}
const getFileIcon = (status: string) => {
switch (status) {
case 'A':
@@ -119,102 +103,7 @@ const getStatusBadgeColor = (status: string) => {
}
};
/**
* Parse unified diff format into structured data
*/
function parseDiff(diffText: string): ParsedFileDiff[] {
if (!diffText) return [];
const files: ParsedFileDiff[] = [];
const lines = diffText.split('\n');
let currentFile: ParsedFileDiff | null = null;
let currentHunk: ParsedDiffHunk | null = null;
let oldLineNum = 0;
let newLineNum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('diff --git')) {
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFile = {
filePath: match ? match[2] : 'unknown',
hunks: [],
};
currentHunk = null;
continue;
}
if (line.startsWith('new file mode')) {
if (currentFile) currentFile.isNew = true;
continue;
}
if (line.startsWith('deleted file mode')) {
if (currentFile) currentFile.isDeleted = true;
continue;
}
if (line.startsWith('rename from') || line.startsWith('rename to')) {
if (currentFile) currentFile.isRenamed = true;
continue;
}
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
continue;
}
if (line.startsWith('@@')) {
if (currentHunk && currentFile) currentFile.hunks.push(currentHunk);
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
currentHunk = {
header: line,
lines: [{ type: 'header', content: line }],
};
continue;
}
if (currentHunk) {
// Skip trailing empty line produced by split('\n') to avoid phantom context line
if (line === '' && i === lines.length - 1) {
continue;
}
if (line.startsWith('+')) {
currentHunk.lines.push({
type: 'addition',
content: line.substring(1),
lineNumber: { new: newLineNum },
});
newLineNum++;
} else if (line.startsWith('-')) {
currentHunk.lines.push({
type: 'deletion',
content: line.substring(1),
lineNumber: { old: oldLineNum },
});
oldLineNum++;
} else if (line.startsWith(' ') || line === '') {
currentHunk.lines.push({
type: 'context',
content: line.substring(1) || '',
lineNumber: { old: oldLineNum, new: newLineNum },
});
oldLineNum++;
newLineNum++;
}
}
}
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
return files;
}
// parseDiff is imported from @/lib/diff-utils
function DiffLine({
type,
@@ -323,8 +212,20 @@ export function CommitWorktreeDialog({
const fileList = result.files ?? [];
if (!cancelled) setFiles(fileList);
if (!cancelled) setDiffContent(result.diff ?? '');
// Select all files by default
if (!cancelled) setSelectedFiles(new Set(fileList.map((f) => f.path)));
// If any files are already staged, pre-select only staged files
// Otherwise select all files by default
const stagedFiles = fileList.filter((f) => {
const idx = f.indexStatus ?? ' ';
return idx !== ' ' && idx !== '?';
});
if (!cancelled) {
if (stagedFiles.length > 0) {
// Also include untracked files that are staged (A status)
setSelectedFiles(new Set(stagedFiles.map((f) => f.path)));
} else {
setSelectedFiles(new Set(fileList.map((f) => f.path)));
}
}
}
}
} catch (err) {
@@ -532,18 +433,14 @@ export function CommitWorktreeDialog({
const isChecked = selectedFiles.has(file.path);
const isExpanded = expandedFile === file.path;
const fileDiff = diffsByFile.get(file.path);
const additions = fileDiff
? fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'addition').length,
0
)
: 0;
const deletions = fileDiff
? fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'deletion').length,
0
)
: 0;
const additions = fileDiff?.additions ?? 0;
const deletions = fileDiff?.deletions ?? 0;
// Determine staging state from index/worktree status
const idx = file.indexStatus ?? ' ';
const wt = file.workTreeStatus ?? ' ';
const isStaged = idx !== ' ' && idx !== '?';
const isUnstaged = wt !== ' ' && wt !== '?';
const isUntracked = idx === '?' && wt === '?';
return (
<div key={file.path} className="border-b border-border last:border-b-0">
@@ -583,6 +480,16 @@ export function CommitWorktreeDialog({
>
{getStatusLabel(file.status)}
</span>
{isStaged && !isUntracked && (
<span className="text-[10px] px-1 py-0.5 rounded border font-medium flex-shrink-0 bg-green-500/15 text-green-400 border-green-500/30">
Staged
</span>
)}
{isStaged && isUnstaged && (
<span className="text-[10px] px-1 py-0.5 rounded border font-medium flex-shrink-0 bg-amber-500/15 text-amber-400 border-amber-500/30">
Partial
</span>
)}
{additions > 0 && (
<span className="text-[10px] text-green-400 flex-shrink-0">
+{additions}

View File

@@ -28,6 +28,7 @@ import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
import type { FileStatus } from '@/types/electron';
import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
interface WorktreeInfo {
path: string;
@@ -44,23 +45,6 @@ interface DiscardWorktreeChangesDialogProps {
onDiscarded: () => void;
}
interface ParsedDiffHunk {
header: string;
lines: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}[];
}
interface ParsedFileDiff {
filePath: string;
hunks: ParsedDiffHunk[];
isNew?: boolean;
isDeleted?: boolean;
isRenamed?: boolean;
}
const getFileIcon = (status: string) => {
switch (status) {
case 'A':
@@ -118,98 +102,7 @@ const getStatusBadgeColor = (status: string) => {
}
};
/**
* Parse unified diff format into structured data
*/
function parseDiff(diffText: string): ParsedFileDiff[] {
if (!diffText) return [];
const files: ParsedFileDiff[] = [];
const lines = diffText.split('\n');
let currentFile: ParsedFileDiff | null = null;
let currentHunk: ParsedDiffHunk | null = null;
let oldLineNum = 0;
let newLineNum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('diff --git')) {
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFile = {
filePath: match ? match[2] : 'unknown',
hunks: [],
};
currentHunk = null;
continue;
}
if (line.startsWith('new file mode')) {
if (currentFile) currentFile.isNew = true;
continue;
}
if (line.startsWith('deleted file mode')) {
if (currentFile) currentFile.isDeleted = true;
continue;
}
if (line.startsWith('rename from') || line.startsWith('rename to')) {
if (currentFile) currentFile.isRenamed = true;
continue;
}
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
continue;
}
if (line.startsWith('@@')) {
if (currentHunk && currentFile) currentFile.hunks.push(currentHunk);
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
currentHunk = {
header: line,
lines: [{ type: 'header', content: line }],
};
continue;
}
if (currentHunk) {
if (line.startsWith('+')) {
currentHunk.lines.push({
type: 'addition',
content: line.substring(1),
lineNumber: { new: newLineNum },
});
newLineNum++;
} else if (line.startsWith('-')) {
currentHunk.lines.push({
type: 'deletion',
content: line.substring(1),
lineNumber: { old: oldLineNum },
});
oldLineNum++;
} else if (line.startsWith(' ') || line === '') {
currentHunk.lines.push({
type: 'context',
content: line.substring(1) || '',
lineNumber: { old: oldLineNum, new: newLineNum },
});
oldLineNum++;
newLineNum++;
}
}
}
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
return files;
}
// parseDiff is imported from @/lib/diff-utils
function DiffLine({
type,

View File

@@ -76,17 +76,6 @@ export function GitPullDialog({
const [pullResult, setPullResult] = useState<PullResult | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Reset state when dialog opens
useEffect(() => {
if (open && worktree) {
setPhase('checking');
setPullResult(null);
setErrorMessage(null);
// Start the initial check
checkForLocalChanges();
}
}, [open, worktree]); // eslint-disable-line react-hooks/exhaustive-deps
const checkForLocalChanges = useCallback(async () => {
if (!worktree) return;
@@ -129,6 +118,17 @@ export function GitPullDialog({
}
}, [worktree, remote, onPulled]);
// Reset state when dialog opens
useEffect(() => {
if (open && worktree) {
setPhase('checking');
setPullResult(null);
setErrorMessage(null);
// Start the initial check
checkForLocalChanges();
}
}, [open, worktree, checkForLocalChanges]);
const handlePullWithStash = useCallback(async () => {
if (!worktree) return;
@@ -154,9 +154,14 @@ export function GitPullDialog({
if (result.result?.hasConflicts) {
setPhase('conflict');
} else {
} else if (result.result?.pulled) {
setPhase('success');
onPulled?.();
} else {
// Unrecognized response: no pulled flag and no conflicts
console.warn('handlePullWithStash: unrecognized response', result.result);
setErrorMessage('Unexpected pull response');
setPhase('error');
}
} catch (err) {
setErrorMessage(err instanceof Error ? err.message : 'Failed to pull');
@@ -300,14 +305,16 @@ export function GitPullDialog({
{pullResult?.message || 'Changes pulled successfully'}
</span>
{pullResult?.stashed && pullResult?.stashRestored && (
<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>
)}
{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>
)}
{pullResult?.stashed &&
(!pullResult?.stashRestored || pullResult?.stashRecoveryFailed) && (

View File

@@ -24,3 +24,8 @@ export { ViewStashesDialog } from './view-stashes-dialog';
export { StashApplyConflictDialog } from './stash-apply-conflict-dialog';
export { CherryPickDialog } from './cherry-pick-dialog';
export { GitPullDialog } from './git-pull-dialog';
export {
BranchConflictDialog,
type BranchConflictData,
type BranchConflictType,
} from './branch-conflict-dialog';

View File

@@ -60,11 +60,6 @@ interface MergeRebaseDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onConfirm: (
worktree: WorktreeInfo,
remoteBranch: string,
strategy: PullStrategy
) => void | Promise<void>;
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
}
@@ -72,7 +67,6 @@ export function MergeRebaseDialog({
open,
onOpenChange,
worktree,
onConfirm,
onCreateConflictResolutionFeature,
}: MergeRebaseDialogProps) {
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
@@ -222,9 +216,6 @@ export function MergeRebaseDialog({
strategy: 'rebase',
});
setStep('conflict');
toast.error('Rebase conflicts detected', {
description: 'Choose how to resolve the conflicts below.',
});
} else {
toast.error('Rebase failed', {
description: result.error || 'Unknown error',
@@ -245,9 +236,6 @@ export function MergeRebaseDialog({
strategy: 'merge',
});
setStep('conflict');
toast.error('Merge conflicts detected', {
description: 'Choose how to resolve the conflicts below.',
});
} else {
toast.success(`Merged ${selectedBranch}`, {
description: result.result.message || 'Merge completed successfully',
@@ -268,53 +256,30 @@ export function MergeRebaseDialog({
strategy: 'merge',
});
setStep('conflict');
toast.error('Merge conflicts detected', {
description: 'Choose how to resolve the conflicts below.',
});
} else {
// Non-conflict failure - fall back to creating a feature task
toast.info('Direct operation failed, creating AI task instead', {
description: result.error || 'The operation will be handled by an AI agent.',
// Non-conflict failure - show conflict resolution UI so user can choose
// how to handle it (resolve manually or with AI) rather than auto-creating a task
setConflictState({
conflictFiles: [],
remoteBranch: selectedBranch,
strategy: 'merge',
});
try {
await onConfirm(worktree, selectedBranch, selectedStrategy);
onOpenChange(false);
} catch (err) {
logger.error('Failed to create feature task:', err);
setStep('select');
}
setStep('conflict');
}
}
}
} catch (err) {
logger.error('Failed to execute operation:', err);
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
const hasConflicts =
errorMessage.toLowerCase().includes('conflict') || errorMessage.includes('CONFLICT');
if (hasConflicts) {
setConflictState({
conflictFiles: [],
remoteBranch: selectedBranch,
strategy: selectedStrategy,
});
setStep('conflict');
} else {
// Fall back to creating a feature task
toast.info('Creating AI task to handle the operation', {
description: 'The operation will be performed by an AI agent.',
});
try {
await onConfirm(worktree, selectedBranch, selectedStrategy);
onOpenChange(false);
} catch (confirmErr) {
logger.error('Failed to create feature task:', confirmErr);
toast.error('Operation failed', { description: errorMessage });
setStep('select');
}
}
// Show conflict resolution UI so user can choose how to handle it
setConflictState({
conflictFiles: [],
remoteBranch: selectedBranch,
strategy: selectedStrategy,
});
setStep('conflict');
}
}, [worktree, selectedBranch, selectedStrategy, selectedRemote, onConfirm, onOpenChange]);
}, [worktree, selectedBranch, selectedStrategy, selectedRemote, onOpenChange]);
const handleResolveWithAI = useCallback(() => {
if (!worktree || !conflictState) return;
@@ -329,13 +294,10 @@ export function MergeRebaseDialog({
};
onCreateConflictResolutionFeature(conflictInfo);
onOpenChange(false);
} else {
// Fallback: create via the onConfirm handler
onConfirm(worktree, conflictState.remoteBranch, conflictState.strategy);
onOpenChange(false);
}
}, [worktree, conflictState, onCreateConflictResolutionFeature, onConfirm, onOpenChange]);
onOpenChange(false);
}, [worktree, conflictState, onCreateConflictResolutionFeature, onOpenChange]);
const handleResolveManually = useCallback(() => {
toast.info('Conflict markers left in place', {

View File

@@ -27,6 +27,7 @@ import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
import type { FileStatus } from '@/types/electron';
import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
interface WorktreeInfo {
path: string;
@@ -43,23 +44,6 @@ interface StashChangesDialogProps {
onStashed?: () => void;
}
interface ParsedDiffHunk {
header: string;
lines: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}[];
}
interface ParsedFileDiff {
filePath: string;
hunks: ParsedDiffHunk[];
isNew?: boolean;
isDeleted?: boolean;
isRenamed?: boolean;
}
const getFileIcon = (status: string) => {
switch (status) {
case 'A':
@@ -117,101 +101,7 @@ const getStatusBadgeColor = (status: string) => {
}
};
/**
* Parse unified diff format into structured data
*/
function parseDiff(diffText: string): ParsedFileDiff[] {
if (!diffText) return [];
const files: ParsedFileDiff[] = [];
const lines = diffText.split('\n');
let currentFile: ParsedFileDiff | null = null;
let currentHunk: ParsedDiffHunk | null = null;
let oldLineNum = 0;
let newLineNum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip trailing empty string produced by a final newline in diffText
if (line === '' && i === lines.length - 1) continue;
if (line.startsWith('diff --git')) {
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFile = {
filePath: match ? match[2] : 'unknown',
hunks: [],
};
currentHunk = null;
continue;
}
if (line.startsWith('new file mode')) {
if (currentFile) currentFile.isNew = true;
continue;
}
if (line.startsWith('deleted file mode')) {
if (currentFile) currentFile.isDeleted = true;
continue;
}
if (line.startsWith('rename from') || line.startsWith('rename to')) {
if (currentFile) currentFile.isRenamed = true;
continue;
}
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
continue;
}
if (line.startsWith('@@')) {
if (currentHunk && currentFile) currentFile.hunks.push(currentHunk);
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
currentHunk = {
header: line,
lines: [{ type: 'header', content: line }],
};
continue;
}
if (currentHunk) {
if (line.startsWith('+')) {
currentHunk.lines.push({
type: 'addition',
content: line.substring(1),
lineNumber: { new: newLineNum },
});
newLineNum++;
} else if (line.startsWith('-')) {
currentHunk.lines.push({
type: 'deletion',
content: line.substring(1),
lineNumber: { old: oldLineNum },
});
oldLineNum++;
} else if (line.startsWith(' ') || line === '') {
currentHunk.lines.push({
type: 'context',
content: line.substring(1) || '',
lineNumber: { old: oldLineNum, new: newLineNum },
});
oldLineNum++;
newLineNum++;
}
}
}
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
return files;
}
// parseDiff is imported from @/lib/diff-utils
function DiffLine({
type,
@@ -316,6 +206,8 @@ export function StashChangesDialog({
// Select all files by default
if (!cancelled.current)
setSelectedFiles(new Set(fileList.map((f: FileStatus) => f.path)));
} else if (!cancelled.current) {
setLoadDiffsError(result.error ?? 'Failed to load diffs');
}
} catch (err) {
console.warn('Failed to load diffs for stash dialog:', err);
@@ -365,7 +257,7 @@ export function StashChangesDialog({
setExpandedFile((prev) => (prev === filePath ? null : filePath));
}, []);
const handleStash = async () => {
const handleStash = useCallback(async () => {
if (!worktree || selectedFiles.size === 0) return;
setIsStashing(true);
@@ -405,14 +297,17 @@ export function StashChangesDialog({
} finally {
setIsStashing(false);
}
};
}, [worktree, selectedFiles, files.length, message, onOpenChange, onStashed]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !isStashing && selectedFiles.size > 0) {
e.preventDefault();
handleStash();
}
};
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !isStashing && selectedFiles.size > 0) {
e.preventDefault();
handleStash();
}
},
[isStashing, selectedFiles.size, handleStash]
);
if (!worktree) return null;
@@ -614,7 +509,13 @@ export function StashChangesDialog({
<p className="text-xs text-muted-foreground">
A descriptive message helps identify this stash later. Press{' '}
<kbd className="px-1 py-0.5 text-[10px] bg-muted rounded border">
{navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+Enter
{typeof navigator !== 'undefined' &&
((navigator as any).userAgentData?.platform || navigator.platform || '').includes(
'Mac'
)
? '⌘'
: 'Ctrl'}
+Enter
</kbd>{' '}
to stash.
</p>

View File

@@ -48,6 +48,9 @@ export function ViewWorktreeChangesDialog({
{worktree.changedFilesCount > 1 ? 's' : ''} changed)
</span>
)}
<span className="ml-1 text-xs text-muted-foreground">
Use the Stage/Unstage buttons to prepare files for commit.
</span>
</DialogDescription>
</DialogHeader>
@@ -58,6 +61,8 @@ export function ViewWorktreeChangesDialog({
featureId={worktree.branch}
useWorktrees={true}
compact={false}
enableStaging={true}
worktreePath={worktree.path}
className="mt-4"
/>
</div>

View File

@@ -37,6 +37,9 @@ import {
History,
Archive,
Cherry,
AlertTriangle,
XCircle,
CheckCircle,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -112,6 +115,10 @@ interface WorktreeActionsDropdownProps {
onViewStashes?: (worktree: WorktreeInfo) => void;
/** Cherry-pick commits from another branch */
onCherryPick?: (worktree: WorktreeInfo) => void;
/** Abort an in-progress merge/rebase/cherry-pick */
onAbortOperation?: (worktree: WorktreeInfo) => void;
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
onContinueOperation?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
@@ -162,6 +169,8 @@ export function WorktreeActionsDropdown({
onStashChanges,
onViewStashes,
onCherryPick,
onAbortOperation,
onContinueOperation,
hasInitScript,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
@@ -233,6 +242,61 @@ export function WorktreeActionsDropdown({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{/* Conflict indicator and actions when merge/rebase/cherry-pick is in progress */}
{worktree.hasConflicts && (
<>
<DropdownMenuLabel className="text-xs flex items-center gap-2 text-red-600 dark:text-red-400">
<AlertTriangle className="w-3.5 h-3.5" />
{worktree.conflictType === 'merge'
? 'Merge'
: worktree.conflictType === 'rebase'
? 'Rebase'
: worktree.conflictType === 'cherry-pick'
? 'Cherry-pick'
: 'Operation'}{' '}
Conflicts
{worktree.conflictFiles && worktree.conflictFiles.length > 0 && (
<span className="ml-auto text-[10px] bg-red-500/20 text-red-600 dark:text-red-400 px-1.5 py-0.5 rounded">
{worktree.conflictFiles.length} file
{worktree.conflictFiles.length !== 1 ? 's' : ''}
</span>
)}
</DropdownMenuLabel>
{onAbortOperation && (
<DropdownMenuItem
onClick={() => onAbortOperation(worktree)}
className="text-xs text-destructive focus:text-destructive"
>
<XCircle className="w-3.5 h-3.5 mr-2" />
Abort{' '}
{worktree.conflictType === 'merge'
? 'Merge'
: worktree.conflictType === 'rebase'
? 'Rebase'
: worktree.conflictType === 'cherry-pick'
? 'Cherry-pick'
: 'Operation'}
</DropdownMenuItem>
)}
{onContinueOperation && (
<DropdownMenuItem
onClick={() => onContinueOperation(worktree)}
className="text-xs text-green-600 focus:text-green-700"
>
<CheckCircle className="w-3.5 h-3.5 mr-2" />
Continue{' '}
{worktree.conflictType === 'merge'
? 'Merge'
: worktree.conflictType === 'rebase'
? 'Rebase'
: worktree.conflictType === 'cherry-pick'
? 'Cherry-pick'
: 'Operation'}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
{/* Loading indicator while git status is being determined */}
{isLoadingGitStatus && (
<>

View File

@@ -1,6 +1,6 @@
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Check, CircleDot, Globe, GitPullRequest, FlaskConical } from 'lucide-react';
import { Check, CircleDot, Globe, GitPullRequest, FlaskConical, AlertTriangle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, DevServerInfo, TestSessionInfo } from '../types';
@@ -8,6 +8,8 @@ import {
truncateBranchName,
getPRBadgeStyles,
getChangesBadgeStyles,
getConflictBadgeStyles,
getConflictTypeLabel,
getTestStatusStyles,
} from './worktree-indicator-utils';
@@ -182,6 +184,20 @@ export function WorktreeDropdownItem({
</span>
)}
{/* Conflict indicator */}
{worktree.hasConflicts && (
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
getConflictBadgeStyles()
)}
title={`${getConflictTypeLabel(worktree.conflictType)} conflicts${worktree.conflictFiles?.length ? ` (${worktree.conflictFiles.length} files)` : ''}`}
>
<AlertTriangle className="w-2.5 h-2.5 mr-0.5" />
{getConflictTypeLabel(worktree.conflictType)}
</span>
)}
{/* PR indicator */}
{pr && (
<span

View File

@@ -16,6 +16,7 @@ import {
Globe,
GitPullRequest,
FlaskConical,
AlertTriangle,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
@@ -34,6 +35,8 @@ import {
truncateBranchName,
getPRBadgeStyles,
getChangesBadgeStyles,
getConflictBadgeStyles,
getConflictTypeLabel,
getTestStatusStyles,
} from './worktree-indicator-utils';
@@ -114,6 +117,10 @@ export interface WorktreeDropdownProps {
onViewStashes?: (worktree: WorktreeInfo) => void;
/** Cherry-pick commits from another branch */
onCherryPick?: (worktree: WorktreeInfo) => void;
/** Abort an in-progress merge/rebase/cherry-pick */
onAbortOperation?: (worktree: WorktreeInfo) => void;
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
onContinueOperation?: (worktree: WorktreeInfo) => void;
}
/**
@@ -195,6 +202,8 @@ export function WorktreeDropdown({
onStashChanges,
onViewStashes,
onCherryPick,
onAbortOperation,
onContinueOperation,
}: WorktreeDropdownProps) {
// Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
@@ -323,6 +332,20 @@ export function WorktreeDropdown({
</span>
)}
{/* Conflict indicator */}
{selectedWorktree?.hasConflicts && (
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-4 px-1 text-[10px] font-medium rounded border shrink-0',
getConflictBadgeStyles()
)}
title={`${getConflictTypeLabel(selectedWorktree.conflictType)} conflicts detected`}
>
<AlertTriangle className="w-2.5 h-2.5 mr-0.5" />
{getConflictTypeLabel(selectedWorktree.conflictType)}
</span>
)}
{/* PR badge */}
{selectedWorktree?.pr && (
<span
@@ -487,6 +510,8 @@ export function WorktreeDropdown({
onStashChanges={onStashChanges}
onViewStashes={onViewStashes}
onCherryPick={onCherryPick}
onAbortOperation={onAbortOperation}
onContinueOperation={onContinueOperation}
hasInitScript={hasInitScript}
/>
)}

View File

@@ -46,6 +46,30 @@ export function getChangesBadgeStyles(): string {
return 'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30';
}
/**
* Returns the CSS classes for the conflict indicator badge.
* Uses red/destructive colors to indicate merge/rebase/cherry-pick conflicts.
*/
export function getConflictBadgeStyles(): string {
return 'bg-red-500/20 text-red-600 dark:text-red-400 border-red-500/30';
}
/**
* Returns a human-readable label for the conflict type.
*/
export function getConflictTypeLabel(conflictType?: 'merge' | 'rebase' | 'cherry-pick'): string {
switch (conflictType) {
case 'merge':
return 'Merge';
case 'rebase':
return 'Rebase';
case 'cherry-pick':
return 'Cherry-pick';
default:
return 'Conflict';
}
}
/** Possible test session status values */
export type TestStatus = 'pending' | 'running' | 'passed' | 'failed' | 'cancelled';

View File

@@ -1,6 +1,6 @@
import type { JSX } from 'react';
import { Button } from '@/components/ui/button';
import { Globe, CircleDot, GitPullRequest } from 'lucide-react';
import { Globe, CircleDot, GitPullRequest, AlertTriangle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -15,6 +15,7 @@ import type {
} from '../types';
import { BranchSwitchDropdown } from './branch-switch-dropdown';
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
import { getConflictBadgeStyles, getConflictTypeLabel } from './worktree-indicator-utils';
interface WorktreeTabProps {
worktree: WorktreeInfo;
@@ -85,6 +86,10 @@ interface WorktreeTabProps {
onViewStashes?: (worktree: WorktreeInfo) => void;
/** Cherry-pick commits from another branch */
onCherryPick?: (worktree: WorktreeInfo) => void;
/** Abort an in-progress merge/rebase/cherry-pick */
onAbortOperation?: (worktree: WorktreeInfo) => void;
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
onContinueOperation?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
/** Whether a test command is configured in project settings */
hasTestCommand?: boolean;
@@ -149,6 +154,8 @@ export function WorktreeTab({
onStashChanges,
onViewStashes,
onCherryPick,
onAbortOperation,
onContinueOperation,
hasInitScript,
hasTestCommand = false,
}: WorktreeTabProps) {
@@ -304,6 +311,29 @@ export function WorktreeTab({
</TooltipContent>
</Tooltip>
)}
{worktree.hasConflicts && (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
isSelected ? 'bg-red-500 text-white border-red-400' : getConflictBadgeStyles()
)}
>
<AlertTriangle className="w-2.5 h-2.5 mr-0.5" />
{getConflictTypeLabel(worktree.conflictType)}
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{getConflictTypeLabel(worktree.conflictType)} conflicts detected
{worktree.conflictFiles && worktree.conflictFiles.length > 0
? ` (${worktree.conflictFiles.length} file${worktree.conflictFiles.length !== 1 ? 's' : ''})`
: ''}
</p>
</TooltipContent>
</Tooltip>
)}
{prBadge}
</Button>
<BranchSwitchDropdown
@@ -371,6 +401,29 @@ export function WorktreeTab({
</TooltipContent>
</Tooltip>
)}
{worktree.hasConflicts && (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
isSelected ? 'bg-red-500 text-white border-red-400' : getConflictBadgeStyles()
)}
>
<AlertTriangle className="w-2.5 h-2.5 mr-0.5" />
{getConflictTypeLabel(worktree.conflictType)}
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{getConflictTypeLabel(worktree.conflictType)} conflicts detected
{worktree.conflictFiles && worktree.conflictFiles.length > 0
? ` (${worktree.conflictFiles.length} file${worktree.conflictFiles.length !== 1 ? 's' : ''})`
: ''}
</p>
</TooltipContent>
</Tooltip>
)}
{prBadge}
</Button>
)}
@@ -463,6 +516,8 @@ export function WorktreeTab({
onStashChanges={onStashChanges}
onViewStashes={onViewStashes}
onCherryPick={onCherryPick}
onAbortOperation={onAbortOperation}
onContinueOperation={onContinueOperation}
hasInitScript={hasInitScript}
/>
</div>

View File

@@ -11,6 +11,12 @@ export interface WorktreeInfo {
hasChanges?: boolean;
changedFilesCount?: number;
pr?: WorktreePRInfo;
/** Whether a merge, rebase, or cherry-pick is in progress with conflicts */
hasConflicts?: boolean;
/** Type of conflict operation in progress */
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
/** List of files with conflicts */
conflictFiles?: string[];
}
export interface BranchInfo {
@@ -81,7 +87,7 @@ export interface MergeConflictInfo {
/** List of files with conflicts, if available */
conflictFiles?: string[];
/** Type of operation that caused the conflict */
operationType?: 'merge' | 'rebase';
operationType?: 'merge' | 'rebase' | 'cherry-pick';
}
export interface BranchSwitchConflictInfo {

View File

@@ -542,6 +542,48 @@ export function WorktreePanel({
fetchWorktrees({ silent: true });
}, [fetchWorktrees]);
// Handle aborting an in-progress merge/rebase/cherry-pick
const handleAbortOperation = useCallback(
async (worktree: WorktreeInfo) => {
try {
const api = getHttpApiClient();
const result = await api.worktree.abortOperation(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message || 'Operation aborted successfully');
fetchWorktrees({ silent: true });
} else {
toast.error(result.error || 'Failed to abort operation');
}
} catch (error) {
toast.error('Failed to abort operation', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
},
[fetchWorktrees]
);
// Handle continuing an in-progress merge/rebase/cherry-pick after conflict resolution
const handleContinueOperation = useCallback(
async (worktree: WorktreeInfo) => {
try {
const api = getHttpApiClient();
const result = await api.worktree.continueOperation(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message || 'Operation continued successfully');
fetchWorktrees({ silent: true });
} else {
toast.error(result.error || 'Failed to continue operation');
}
} catch (error) {
toast.error('Failed to continue operation', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
},
[fetchWorktrees]
);
// Handle opening the log panel for a specific worktree
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
setLogPanelWorktree(worktree);
@@ -771,6 +813,8 @@ export function WorktreePanel({
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
/>
)}
@@ -989,6 +1033,8 @@ export function WorktreePanel({
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
/>
{useWorktreesEnabled && (
@@ -1086,6 +1132,8 @@ export function WorktreePanel({
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>
@@ -1163,6 +1211,8 @@ export function WorktreePanel({
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>

View File

@@ -197,8 +197,43 @@ export function OpencodeModelConfiguration({
onDynamicModelToggle,
isLoadingDynamicModels = false,
}: OpencodeModelConfigurationProps) {
// Determine the free tier models to display.
// When dynamic models are available from CLI, use the opencode provider models
// from the dynamic list (they reflect the actual currently-available models).
// Fall back to the hardcoded OPENCODE_MODELS only when CLI hasn't returned data.
const dynamicOpencodeFreeModels = useMemo(() => {
const opencodeModelsFromCli = dynamicModels.filter((m) => m.provider === 'opencode');
if (opencodeModelsFromCli.length === 0) return null;
// Convert dynamic ModelDefinition to OpencodeModelConfig for the static section
return opencodeModelsFromCli.map(
(m): OpencodeModelConfig => ({
id: m.id.replace('opencode/', 'opencode-') as OpencodeModelId,
label: m.name.replace(/\s*\(Free\)\s*$/, '').replace(/\s*\(OpenCode\)\s*$/, ''),
description: m.description,
supportsVision: m.supportsVision ?? false,
provider: 'opencode' as OpencodeProvider,
tier: 'free',
})
);
}, [dynamicModels]);
// Use dynamically discovered free tier models when available, otherwise hardcoded fallback
const effectiveStaticModels = dynamicOpencodeFreeModels ?? OPENCODE_MODELS;
// Build an effective config map that includes dynamic models (for default model dropdown lookup)
const effectiveModelConfigMap = useMemo(() => {
const map = { ...OPENCODE_MODEL_CONFIG_MAP };
if (dynamicOpencodeFreeModels) {
for (const model of dynamicOpencodeFreeModels) {
map[model.id] = model;
}
}
return map;
}, [dynamicOpencodeFreeModels]);
// Group static models by provider for organized display
const modelsByProvider = OPENCODE_MODELS.reduce(
const modelsByProvider = effectiveStaticModels.reduce(
(acc, model) => {
if (!acc[model.provider]) {
acc[model.provider] = [];
@@ -217,7 +252,7 @@ export function OpencodeModelConfiguration({
const [dynamicProviderSearch, setDynamicProviderSearch] = useState('');
const normalizedDynamicSearch = dynamicProviderSearch.trim().toLowerCase();
const hasDynamicSearch = normalizedDynamicSearch.length > 0;
const allStaticModelIds = OPENCODE_MODELS.map((model) => model.id);
const allStaticModelIds = effectiveStaticModels.map((model) => model.id);
const selectableStaticModelIds = allStaticModelIds.filter(
(modelId) => modelId !== opencodeDefaultModel
);
@@ -378,7 +413,7 @@ export function OpencodeModelConfiguration({
</SelectTrigger>
<SelectContent>
{enabledOpencodeModels.map((modelId) => {
const model = OPENCODE_MODEL_CONFIG_MAP[modelId];
const model = effectiveModelConfigMap[modelId];
if (!model) return null;
const ModelIconComponent = getModelIcon(modelId);
return (

View File

@@ -22,6 +22,12 @@ interface WorktreeInfo {
changedFilesCount?: number;
featureId?: string;
linkedToBranch?: string;
/** Whether a merge, rebase, or cherry-pick is in progress with conflicts */
hasConflicts?: boolean;
/** Type of conflict operation in progress */
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
/** List of files with conflicts */
conflictFiles?: string[];
}
interface RemovedWorktree {

View File

@@ -36,6 +36,7 @@ export function formatModelName(model: string): string {
// Claude models
if (model.includes('opus-4-6') || model === 'claude-opus') return 'Opus 4.6';
if (model.includes('opus')) return 'Opus 4.5';
if (model.includes('sonnet-4-6') || model === 'claude-sonnet') return 'Sonnet 4.6';
if (model.includes('sonnet')) return 'Sonnet 4.5';
if (model.includes('haiku')) return 'Haiku 4.5';

View File

@@ -0,0 +1,133 @@
/**
* Shared diff parsing utilities.
*
* Extracted from commit-worktree-dialog, discard-worktree-changes-dialog,
* stash-changes-dialog and git-diff-panel to eliminate duplication.
*/
export interface ParsedDiffHunk {
header: string;
lines: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}[];
}
export interface ParsedFileDiff {
filePath: string;
hunks: ParsedDiffHunk[];
isNew?: boolean;
isDeleted?: boolean;
isRenamed?: boolean;
/** Pre-computed count of added lines across all hunks */
additions: number;
/** Pre-computed count of deleted lines across all hunks */
deletions: number;
}
/**
* Parse unified diff format into structured data.
*
* Note: The regex `diff --git a\/(.*?) b\/(.*)` uses a non-greedy match for
* the `a/` path and a greedy match for `b/`. This can mis-handle paths that
* literally contain " b/" or are quoted by git. In practice this covers the
* vast majority of real-world paths; exotic cases will fall back to "unknown".
*/
export function parseDiff(diffText: string): ParsedFileDiff[] {
if (!diffText) return [];
const files: ParsedFileDiff[] = [];
const lines = diffText.split('\n');
let currentFile: ParsedFileDiff | null = null;
let currentHunk: ParsedDiffHunk | null = null;
let oldLineNum = 0;
let newLineNum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('diff --git')) {
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFile = {
filePath: match ? match[2] : 'unknown',
hunks: [],
additions: 0,
deletions: 0,
};
currentHunk = null;
continue;
}
if (line.startsWith('new file mode')) {
if (currentFile) currentFile.isNew = true;
continue;
}
if (line.startsWith('deleted file mode')) {
if (currentFile) currentFile.isDeleted = true;
continue;
}
if (line.startsWith('rename from') || line.startsWith('rename to')) {
if (currentFile) currentFile.isRenamed = true;
continue;
}
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
continue;
}
if (line.startsWith('@@')) {
if (currentHunk && currentFile) currentFile.hunks.push(currentHunk);
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
currentHunk = {
header: line,
lines: [{ type: 'header', content: line }],
};
continue;
}
if (currentHunk) {
// Skip trailing empty line produced by split('\n') to avoid phantom context line
if (line === '' && i === lines.length - 1) {
continue;
}
if (line.startsWith('+')) {
currentHunk.lines.push({
type: 'addition',
content: line.substring(1),
lineNumber: { new: newLineNum },
});
newLineNum++;
if (currentFile) currentFile.additions++;
} else if (line.startsWith('-')) {
currentHunk.lines.push({
type: 'deletion',
content: line.substring(1),
lineNumber: { old: oldLineNum },
});
oldLineNum++;
if (currentFile) currentFile.deletions++;
} else if (line.startsWith(' ') || line === '') {
currentHunk.lines.push({
type: 'context',
content: line.substring(1) || '',
lineNumber: { old: oldLineNum, new: newLineNum },
});
oldLineNum++;
newLineNum++;
}
}
}
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
return files;
}

View File

@@ -2259,6 +2259,17 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
stageFiles: async (worktreePath: string, files: string[], operation: 'stage' | 'unstage') => {
console.log('[Mock] Stage files:', { worktreePath, files, operation });
return {
success: true,
result: {
operation,
filesCount: files.length,
},
};
},
pull: async (worktreePath: string, remote?: string, stashIfNeeded?: boolean) => {
const targetRemote = remote || 'origin';
console.log('[Mock] Pulling latest changes for:', {
@@ -2760,6 +2771,28 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
};
},
abortOperation: async (worktreePath: string) => {
console.log('[Mock] Abort operation:', { worktreePath });
return {
success: true,
result: {
operation: 'merge',
message: 'Merge aborted successfully',
},
};
},
continueOperation: async (worktreePath: string) => {
console.log('[Mock] Continue operation:', { worktreePath });
return {
success: true,
result: {
operation: 'merge',
message: 'Merge continued successfully',
},
};
},
};
}
@@ -2787,6 +2820,17 @@ function createMockGitAPI(): GitAPI {
filePath,
};
},
stageFiles: async (projectPath: string, files: string[], operation: 'stage' | 'unstage') => {
console.log('[Mock] Git stage files:', { projectPath, files, operation });
return {
success: true,
result: {
operation,
filesCount: files.length,
},
};
},
};
}

View File

@@ -2135,6 +2135,8 @@ export class HttpApiClient implements ElectronAPI {
featureId,
filePath,
}),
stageFiles: (worktreePath: string, files: string[], operation: 'stage' | 'unstage') =>
this.post('/api/worktree/stage-files', { worktreePath, files, operation }),
pull: (worktreePath: string, remote?: string, stashIfNeeded?: boolean) =>
this.post('/api/worktree/pull', { worktreePath, remote, stashIfNeeded }),
checkoutBranch: (worktreePath: string, branchName: string, baseBranch?: string) =>
@@ -2232,6 +2234,10 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/worktree/cherry-pick', { worktreePath, commitHashes, options }),
rebase: (worktreePath: string, ontoBranch: string) =>
this.post('/api/worktree/rebase', { worktreePath, ontoBranch }),
abortOperation: (worktreePath: string) =>
this.post('/api/worktree/abort-operation', { worktreePath }),
continueOperation: (worktreePath: string) =>
this.post('/api/worktree/continue-operation', { worktreePath }),
getBranchCommitLog: (worktreePath: string, branchName?: string, limit?: number) =>
this.post('/api/worktree/branch-commit-log', { worktreePath, branchName, limit }),
getTestLogs: (worktreePath?: string, sessionId?: string): Promise<TestLogsResponse> => {
@@ -2263,6 +2269,8 @@ export class HttpApiClient implements ElectronAPI {
getDiffs: (projectPath: string) => this.post('/api/git/diffs', { projectPath }),
getFileDiff: (projectPath: string, filePath: string) =>
this.post('/api/git/file-diff', { projectPath, filePath }),
stageFiles: (projectPath: string, files: string[], operation: 'stage' | 'unstage') =>
this.post('/api/git/stage-files', { projectPath, files, operation }),
};
// Spec Regeneration API

View File

@@ -755,6 +755,10 @@ export interface FileStatus {
status: string;
path: string;
statusText: string;
/** Raw staging area (index) status character from git porcelain format */
indexStatus?: string;
/** Raw working tree status character from git porcelain format */
workTreeStatus?: string;
}
export interface FileDiffsResult {
@@ -985,6 +989,20 @@ export interface WorktreeAPI {
filePath: string
) => Promise<FileDiffResult>;
// Stage or unstage files in a worktree
stageFiles: (
worktreePath: string,
files: string[],
operation: 'stage' | 'unstage'
) => Promise<{
success: boolean;
result?: {
operation: 'stage' | 'unstage';
filesCount: number;
};
error?: string;
}>;
// Pull latest changes from remote with optional stash management
pull: (
worktreePath: string,
@@ -1622,6 +1640,20 @@ export interface GitAPI {
// Get diff for a specific file in the main project
getFileDiff: (projectPath: string, filePath: string) => Promise<FileDiffResult>;
// Stage or unstage files in the main project
stageFiles: (
projectPath: string,
files: string[],
operation: 'stage' | 'unstage'
) => Promise<{
success: boolean;
result?: {
operation: 'stage' | 'unstage';
filesCount: number;
};
error?: string;
}>;
}
// Model definition type

View File

@@ -99,6 +99,8 @@ export function parseGitStatus(statusOutput: string): FileStatus[] {
status: primaryStatus,
path: filePath,
statusText: getStatusText(indexStatus, workTreeStatus),
indexStatus,
workTreeStatus,
};
});
}

View File

@@ -70,4 +70,8 @@ export interface FileStatus {
status: string;
path: string;
statusText: string;
/** Raw staging area (index) status character from git porcelain format */
indexStatus?: string;
/** Raw working tree status character from git porcelain format */
workTreeStatus?: string;
}

View File

@@ -11,7 +11,7 @@
*
* With canonical model IDs:
* - Cursor: cursor-auto, cursor-composer-1, cursor-gpt-5.2
* - OpenCode: opencode-big-pickle, opencode-grok-code
* - OpenCode: opencode-big-pickle, opencode-kimi-k2.5-free
* - Copilot: copilot-gpt-5.1, copilot-claude-sonnet-4.5, copilot-gemini-3-pro-preview
* - Gemini: gemini-2.5-flash, gemini-2.5-pro
* - Claude: claude-haiku, claude-sonnet, claude-opus (also supports legacy aliases)
@@ -110,7 +110,7 @@ export function resolveModelString(
return resolved;
}
// Full Claude model string (e.g., claude-sonnet-4-5-20250929) - pass through
// Full Claude model string (e.g., claude-sonnet-4-6) - pass through
if (canonicalKey.includes('claude-')) {
console.log(`[ModelResolver] Using full Claude model string: ${canonicalKey}`);
return canonicalKey;

View File

@@ -42,7 +42,7 @@ describe('model-resolver', () => {
describe('with full Claude model strings', () => {
it('should pass through full Claude model string unchanged', () => {
const fullModel = 'claude-sonnet-4-20250514';
const fullModel = 'claude-sonnet-4-6';
const result = resolveModelString(fullModel);
expect(result).toBe(fullModel);
@@ -243,7 +243,7 @@ describe('model-resolver', () => {
describe('priority handling', () => {
it('should prioritize explicit model over all others', () => {
const explicit = 'claude-opus-4-20241113';
const session = 'claude-sonnet-4-20250514';
const session = 'claude-sonnet-4-6';
const defaultModel = 'claude-3-5-haiku-20241022';
const result = getEffectiveModel(explicit, session, defaultModel);
@@ -252,7 +252,7 @@ describe('model-resolver', () => {
});
it('should use session model when explicit is undefined', () => {
const session = 'claude-sonnet-4-20250514';
const session = 'claude-sonnet-4-6';
const defaultModel = 'claude-3-5-haiku-20241022';
const result = getEffectiveModel(undefined, session, defaultModel);
@@ -297,7 +297,7 @@ describe('model-resolver', () => {
describe('with empty strings', () => {
it('should treat empty explicit string as undefined', () => {
const session = 'claude-sonnet-4-20250514';
const session = 'claude-sonnet-4-6';
const result = getEffectiveModel('', session);
@@ -324,7 +324,7 @@ describe('model-resolver', () => {
describe('integration scenarios', () => {
it('should handle user overriding session model with alias', () => {
const sessionModel = 'claude-sonnet-4-20250514';
const sessionModel = 'claude-sonnet-4-6';
const userChoice = 'opus';
const result = getEffectiveModel(userChoice, sessionModel);
@@ -418,7 +418,7 @@ describe('model-resolver', () => {
});
it('should pass through full Claude model string', () => {
const fullModel = 'claude-sonnet-4-20250514';
const fullModel = 'claude-sonnet-4-6';
const result = resolvePhaseModel(fullModel);
expect(result.model).toBe(fullModel);

View File

@@ -30,6 +30,13 @@ export interface CopilotModelConfig {
*/
export const COPILOT_MODEL_MAP = {
// Claude models (Anthropic via GitHub Copilot)
'copilot-claude-sonnet-4.6': {
label: 'Claude Sonnet 4.6',
description: 'Anthropic Claude Sonnet 4.6 via GitHub Copilot.',
supportsVision: true,
supportsTools: true,
contextWindow: 200000,
},
'copilot-claude-sonnet-4.5': {
label: 'Claude Sonnet 4.5',
description: 'Anthropic Claude Sonnet 4.5 via GitHub Copilot.',
@@ -147,7 +154,7 @@ export function getAllCopilotModelIds(): CopilotModelId[] {
/**
* Default Copilot model
*/
export const DEFAULT_COPILOT_MODEL: CopilotModelId = 'copilot-claude-sonnet-4.5';
export const DEFAULT_COPILOT_MODEL: CopilotModelId = 'copilot-claude-sonnet-4.6';
/**
* GitHub Copilot authentication status

View File

@@ -8,6 +8,8 @@
export type CursorModelId =
| 'cursor-auto' // Auto-select best model
| 'cursor-composer-1' // Cursor Composer agent model
| 'cursor-sonnet-4.6' // Claude Sonnet 4.6
| 'cursor-sonnet-4.6-thinking' // Claude Sonnet 4.6 with extended thinking
| 'cursor-sonnet-4.5' // Claude Sonnet 4.5
| 'cursor-sonnet-4.5-thinking' // Claude Sonnet 4.5 with extended thinking
| 'cursor-opus-4.5' // Claude Opus 4.5
@@ -35,6 +37,8 @@ export type CursorModelId =
export type LegacyCursorModelId =
| 'auto'
| 'composer-1'
| 'sonnet-4.6'
| 'sonnet-4.6-thinking'
| 'sonnet-4.5'
| 'sonnet-4.5-thinking'
| 'opus-4.5'
@@ -75,6 +79,20 @@ export const CURSOR_MODEL_MAP: Record<CursorModelId, CursorModelConfig> = {
hasThinking: false,
supportsVision: false,
},
'cursor-sonnet-4.6': {
id: 'cursor-sonnet-4.6',
label: 'Claude Sonnet 4.6',
description: 'Anthropic Claude Sonnet 4.6 via Cursor',
hasThinking: false,
supportsVision: false, // Model supports vision but Cursor CLI doesn't pass images
},
'cursor-sonnet-4.6-thinking': {
id: 'cursor-sonnet-4.6-thinking',
label: 'Claude Sonnet 4.6 (Thinking)',
description: 'Claude Sonnet 4.6 with extended thinking enabled',
hasThinking: true,
supportsVision: false,
},
'cursor-sonnet-4.5': {
id: 'cursor-sonnet-4.5',
label: 'Claude Sonnet 4.5',
@@ -223,6 +241,8 @@ export const CURSOR_MODEL_MAP: Record<CursorModelId, CursorModelConfig> = {
export const LEGACY_CURSOR_MODEL_MAP: Record<LegacyCursorModelId, CursorModelId> = {
auto: 'cursor-auto',
'composer-1': 'cursor-composer-1',
'sonnet-4.6': 'cursor-sonnet-4.6',
'sonnet-4.6-thinking': 'cursor-sonnet-4.6-thinking',
'sonnet-4.5': 'cursor-sonnet-4.5',
'sonnet-4.5-thinking': 'cursor-sonnet-4.5-thinking',
'opus-4.5': 'cursor-opus-4.5',
@@ -378,6 +398,22 @@ export const CURSOR_MODEL_GROUPS: GroupedModel[] = [
},
],
},
// Sonnet 4.6 group (thinking mode)
{
baseId: 'cursor-sonnet-4.6-group',
label: 'Claude Sonnet 4.6',
description: 'Anthropic Claude Sonnet 4.6 via Cursor',
variantType: 'thinking',
variants: [
{ id: 'cursor-sonnet-4.6', label: 'Standard', description: 'Fast responses' },
{
id: 'cursor-sonnet-4.6-thinking',
label: 'Thinking',
description: 'Extended reasoning',
badge: 'Reasoning',
},
],
},
// Sonnet 4.5 group (thinking mode)
{
baseId: 'cursor-sonnet-4.5-group',

View File

@@ -253,7 +253,7 @@ export const REASONING_EFFORT_LABELS: Record<ReasoningEffort, string> = {
* ```typescript
* getModelDisplayName("haiku"); // "Claude Haiku"
* getModelDisplayName("sonnet"); // "Claude Sonnet"
* getModelDisplayName("claude-opus-4-20250514"); // "claude-opus-4-20250514"
* getModelDisplayName("claude-sonnet-4-6"); // "Claude Sonnet 4.6"
* ```
*/
export function getModelDisplayName(model: ModelAlias | string): string {
@@ -261,6 +261,11 @@ export function getModelDisplayName(model: ModelAlias | string): string {
haiku: 'Claude Haiku',
sonnet: 'Claude Sonnet',
opus: 'Claude Opus',
'claude-haiku': 'Claude Haiku',
'claude-sonnet': 'Claude Sonnet',
'claude-opus': 'Claude Opus',
'claude-sonnet-4-6': 'Claude Sonnet 4.6',
'claude-opus-4-6': 'Claude Opus 4.6',
[CODEX_MODEL_MAP.gpt53Codex]: 'GPT-5.3-Codex',
[CODEX_MODEL_MAP.gpt53CodexSpark]: 'GPT-5.3-Codex-Spark',
[CODEX_MODEL_MAP.gpt52Codex]: 'GPT-5.2-Codex',

View File

@@ -8,7 +8,11 @@
import type { CursorModelId, LegacyCursorModelId } from './cursor-models.js';
import { LEGACY_CURSOR_MODEL_MAP, CURSOR_MODEL_MAP } from './cursor-models.js';
import type { OpencodeModelId, LegacyOpencodeModelId } from './opencode-models.js';
import { LEGACY_OPENCODE_MODEL_MAP, OPENCODE_MODEL_CONFIG_MAP } from './opencode-models.js';
import {
LEGACY_OPENCODE_MODEL_MAP,
OPENCODE_MODEL_CONFIG_MAP,
RETIRED_OPENCODE_MODEL_MAP,
} from './opencode-models.js';
import type { ClaudeCanonicalId } from './model.js';
import { LEGACY_CLAUDE_ALIAS_MAP, CLAUDE_CANONICAL_MAP, CLAUDE_MODEL_MAP } from './model.js';
import type { PhaseModelEntry } from './settings.js';
@@ -61,11 +65,16 @@ export function migrateModelId(legacyId: string | undefined | null): string {
return LEGACY_CURSOR_MODEL_MAP[legacyId];
}
// Already has opencode- prefix - it's canonical
// Already has opencode- prefix - check if it's a current canonical ID
if (legacyId.startsWith('opencode-') && legacyId in OPENCODE_MODEL_CONFIG_MAP) {
return legacyId;
}
// Retired opencode- canonical IDs (e.g., 'opencode-grok-code' → 'opencode-big-pickle')
if (legacyId.startsWith('opencode-') && legacyId in RETIRED_OPENCODE_MODEL_MAP) {
return RETIRED_OPENCODE_MODEL_MAP[legacyId];
}
// Legacy OpenCode model ID (with slash format)
if (isLegacyOpencodeModelId(legacyId)) {
return LEGACY_OPENCODE_MODEL_MAP[legacyId];
@@ -128,29 +137,36 @@ export function migrateOpencodeModelIds(ids: string[]): OpencodeModelId[] {
return [];
}
return ids.map((id) => {
// Already canonical (dash format)
if (id.startsWith('opencode-') && id in OPENCODE_MODEL_CONFIG_MAP) {
return ids
.map((id) => {
// Already canonical (dash format) and current
if (id.startsWith('opencode-') && id in OPENCODE_MODEL_CONFIG_MAP) {
return id as OpencodeModelId;
}
// Retired canonical IDs (e.g., 'opencode-grok-code') → replacement
if (id.startsWith('opencode-') && id in RETIRED_OPENCODE_MODEL_MAP) {
return RETIRED_OPENCODE_MODEL_MAP[id];
}
// Legacy ID (slash format)
if (isLegacyOpencodeModelId(id)) {
return LEGACY_OPENCODE_MODEL_MAP[id];
}
// Convert slash to dash format for unknown models
if (id.startsWith('opencode/')) {
return id.replace('opencode/', 'opencode-') as OpencodeModelId;
}
// Add prefix if not present
if (!id.startsWith('opencode-')) {
return `opencode-${id}` as OpencodeModelId;
}
return id as OpencodeModelId;
}
// Legacy ID (slash format)
if (isLegacyOpencodeModelId(id)) {
return LEGACY_OPENCODE_MODEL_MAP[id];
}
// Convert slash to dash format for unknown models
if (id.startsWith('opencode/')) {
return id.replace('opencode/', 'opencode-') as OpencodeModelId;
}
// Add prefix if not present
if (!id.startsWith('opencode-')) {
return `opencode-${id}` as OpencodeModelId;
}
return id as OpencodeModelId;
});
})
.filter((id, index, self) => self.indexOf(id) === index); // Deduplicate after migration
}
/**

View File

@@ -17,7 +17,7 @@ export type ClaudeCanonicalId = 'claude-haiku' | 'claude-sonnet' | 'claude-opus'
*/
export const CLAUDE_CANONICAL_MAP: Record<ClaudeCanonicalId, string> = {
'claude-haiku': 'claude-haiku-4-5-20251001',
'claude-sonnet': 'claude-sonnet-4-5-20250929',
'claude-sonnet': 'claude-sonnet-4-6',
'claude-opus': 'claude-opus-4-6',
} as const;
@@ -28,7 +28,7 @@ export const CLAUDE_CANONICAL_MAP: Record<ClaudeCanonicalId, string> = {
*/
export const CLAUDE_MODEL_MAP: Record<string, string> = {
haiku: 'claude-haiku-4-5-20251001',
sonnet: 'claude-sonnet-4-5-20250929',
sonnet: 'claude-sonnet-4-6',
opus: 'claude-opus-4-6',
} as const;

View File

@@ -8,18 +8,23 @@
export type OpencodeModelId =
// OpenCode Free Tier Models
| 'opencode-big-pickle'
| 'opencode-glm-4.7-free'
| 'opencode-glm-5-free'
| 'opencode-gpt-5-nano'
| 'opencode-grok-code'
| 'opencode-minimax-m2.1-free';
| 'opencode-kimi-k2.5-free'
| 'opencode-minimax-m2.5-free';
/**
* Legacy OpenCode model IDs (with slash format) for migration support
* Includes both current and previously-available models for backward compatibility.
*/
export type LegacyOpencodeModelId =
| 'opencode/big-pickle'
| 'opencode/glm-4.7-free'
| 'opencode/glm-5-free'
| 'opencode/gpt-5-nano'
| 'opencode/kimi-k2.5-free'
| 'opencode/minimax-m2.5-free'
// Retired models (kept for migration from older settings)
| 'opencode/glm-4.7-free'
| 'opencode/grok-code'
| 'opencode/minimax-m2.1-free';
@@ -35,23 +40,40 @@ export const OPENCODE_MODEL_MAP: Record<string, OpencodeModelId> = {
// OpenCode free tier aliases
'big-pickle': 'opencode-big-pickle',
pickle: 'opencode-big-pickle',
'glm-free': 'opencode-glm-4.7-free',
'glm-free': 'opencode-glm-5-free',
'glm-5': 'opencode-glm-5-free',
'gpt-nano': 'opencode-gpt-5-nano',
nano: 'opencode-gpt-5-nano',
'grok-code': 'opencode-grok-code',
grok: 'opencode-grok-code',
minimax: 'opencode-minimax-m2.1-free',
'kimi-free': 'opencode-kimi-k2.5-free',
kimi: 'opencode-kimi-k2.5-free',
minimax: 'opencode-minimax-m2.5-free',
} as const;
/**
* Map from legacy slash-format model IDs to canonical prefixed IDs
* Map from legacy slash-format model IDs to canonical prefixed IDs.
* Retired models are mapped to their closest replacement.
*/
export const LEGACY_OPENCODE_MODEL_MAP: Record<LegacyOpencodeModelId, OpencodeModelId> = {
// Current models
'opencode/big-pickle': 'opencode-big-pickle',
'opencode/glm-4.7-free': 'opencode-glm-4.7-free',
'opencode/glm-5-free': 'opencode-glm-5-free',
'opencode/gpt-5-nano': 'opencode-gpt-5-nano',
'opencode/grok-code': 'opencode-grok-code',
'opencode/minimax-m2.1-free': 'opencode-minimax-m2.1-free',
'opencode/kimi-k2.5-free': 'opencode-kimi-k2.5-free',
'opencode/minimax-m2.5-free': 'opencode-minimax-m2.5-free',
// Retired models → mapped to replacements
'opencode/glm-4.7-free': 'opencode-glm-5-free',
'opencode/grok-code': 'opencode-big-pickle', // grok-code retired, fallback to default
'opencode/minimax-m2.1-free': 'opencode-minimax-m2.5-free',
};
/**
* Map from retired canonical (dash-format) model IDs to their replacements.
* Used to migrate settings that reference models no longer available.
*/
export const RETIRED_OPENCODE_MODEL_MAP: Record<string, OpencodeModelId> = {
'opencode-glm-4.7-free': 'opencode-glm-5-free',
'opencode-grok-code': 'opencode-big-pickle',
'opencode-minimax-m2.1-free': 'opencode-minimax-m2.5-free',
};
/**
@@ -81,8 +103,8 @@ export const OPENCODE_MODELS: OpencodeModelConfig[] = [
tier: 'free',
},
{
id: 'opencode-glm-4.7-free',
label: 'GLM 4.7 Free',
id: 'opencode-glm-5-free',
label: 'GLM 5 Free',
description: 'OpenCode free tier GLM model',
supportsVision: false,
provider: 'opencode',
@@ -97,16 +119,16 @@ export const OPENCODE_MODELS: OpencodeModelConfig[] = [
tier: 'free',
},
{
id: 'opencode-grok-code',
label: 'Grok Code',
description: 'OpenCode free tier Grok model for coding',
id: 'opencode-kimi-k2.5-free',
label: 'Kimi K2.5 Free',
description: 'OpenCode free tier Kimi model for coding',
supportsVision: false,
provider: 'opencode',
tier: 'free',
},
{
id: 'opencode-minimax-m2.1-free',
label: 'MiniMax M2.1 Free',
id: 'opencode-minimax-m2.5-free',
label: 'MiniMax M2.5 Free',
description: 'OpenCode free tier MiniMax model',
supportsVision: false,
provider: 'opencode',
@@ -160,7 +182,8 @@ export function getOpencodeModelProvider(modelId: OpencodeModelId): OpencodeProv
}
/**
* Helper: Resolve an alias or partial model ID to a full model ID
* Helper: Resolve an alias or partial model ID to a full model ID.
* Also handles retired model IDs by mapping them to their replacements.
*/
export function resolveOpencodeModelId(input: string): OpencodeModelId | undefined {
// Check if it's already a valid model ID
@@ -168,6 +191,11 @@ export function resolveOpencodeModelId(input: string): OpencodeModelId | undefin
return input as OpencodeModelId;
}
// Check retired model map (handles old canonical IDs like 'opencode-grok-code')
if (input in RETIRED_OPENCODE_MODEL_MAP) {
return RETIRED_OPENCODE_MODEL_MAP[input];
}
// Check alias map
const normalized = input.toLowerCase();
return OPENCODE_MODEL_MAP[normalized];

View File

@@ -9,7 +9,11 @@
import type { ModelProvider } from './settings.js';
import { LEGACY_CURSOR_MODEL_MAP } from './cursor-models.js';
import { CLAUDE_MODEL_MAP, CODEX_MODEL_MAP } from './model.js';
import { OPENCODE_MODEL_CONFIG_MAP, LEGACY_OPENCODE_MODEL_MAP } from './opencode-models.js';
import {
OPENCODE_MODEL_CONFIG_MAP,
LEGACY_OPENCODE_MODEL_MAP,
RETIRED_OPENCODE_MODEL_MAP,
} from './opencode-models.js';
import { GEMINI_MODEL_MAP } from './gemini-models.js';
import { COPILOT_MODEL_MAP } from './copilot-models.js';
@@ -51,7 +55,7 @@ export function isCursorModel(model: string | undefined | null): boolean {
/**
* Check if a model string represents a Claude model
*
* @param model - Model string to check (e.g., "sonnet", "opus", "claude-sonnet-4-20250514")
* @param model - Model string to check (e.g., "sonnet", "opus", "claude-sonnet-4-6")
* @returns true if the model is a Claude model
*/
export function isClaudeModel(model: string | undefined | null): boolean {
@@ -310,7 +314,10 @@ export function getBareModelId(model: string): string {
export function normalizeModelString(model: string | undefined | null): string {
if (!model || typeof model !== 'string') return 'claude-sonnet'; // Default to canonical
// Already has a canonical prefix - return as-is
// Already has a canonical prefix - return as-is (but check for retired opencode models first)
if (model.startsWith(PROVIDER_PREFIXES.opencode) && model in RETIRED_OPENCODE_MODEL_MAP) {
return RETIRED_OPENCODE_MODEL_MAP[model];
}
if (
model.startsWith(PROVIDER_PREFIXES.cursor) ||
model.startsWith(PROVIDER_PREFIXES.codex) ||
@@ -364,7 +371,7 @@ export function normalizeModelString(model: string | undefined | null): string {
*
* @example
* supportsStructuredOutput('sonnet') // true (Claude)
* supportsStructuredOutput('claude-sonnet-4-20250514') // true (Claude)
* supportsStructuredOutput('claude-sonnet-4-6') // true (Claude)
* supportsStructuredOutput('codex-gpt-5.2') // true (Codex/OpenAI)
* supportsStructuredOutput('cursor-auto') // false
* supportsStructuredOutput('gemini-2.5-pro') // false