diff --git a/apps/server/src/lib/git.ts b/apps/server/src/lib/git.ts index 288f8e89..cda5f71b 100644 --- a/apps/server/src/lib/git.ts +++ b/apps/server/src/lib/git.ts @@ -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 { 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 { + 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 +): Promise { + 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); + } +} diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index fa3eb923..54ef00f6 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -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', diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index 178760c6..756d5e95 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -245,15 +245,9 @@ async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise): string | null { diff --git a/apps/server/src/providers/codex-sdk-client.ts b/apps/server/src/providers/codex-sdk-client.ts index ced24510..bc885c72 100644 --- a/apps/server/src/providers/codex-sdk-client.ts +++ b/apps/server/src/providers/codex-sdk-client.ts @@ -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(['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(['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) diff --git a/apps/server/src/providers/copilot-provider.ts b/apps/server/src/providers/copilot-provider.ts index ad6e155e..34cfcbce 100644 --- a/apps/server/src/providers/copilot-provider.ts +++ b/apps/server/src/providers/copilot-provider.ts @@ -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) diff --git a/apps/server/src/providers/opencode-provider.ts b/apps/server/src/providers/opencode-provider.ts index 07068b08..76d92483 100644 --- a/apps/server/src/providers/opencode-provider.ts +++ b/apps/server/src/providers/opencode-provider.ts @@ -549,8 +549,15 @@ export class OpencodeProvider extends CliProvider { // sdkSessionId IS set — the CLI will receive `--session `. // 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 * ... diff --git a/apps/server/src/providers/simple-query-service.ts b/apps/server/src/providers/simple-query-service.ts index 1250e6f8..5ebe4db9 100644 --- a/apps/server/src/providers/simple-query-service.ts +++ b/apps/server/src/providers/simple-query-service.ts @@ -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 diff --git a/apps/server/src/routes/git/index.ts b/apps/server/src/routes/git/index.ts index 5e959ec9..da6adabf 100644 --- a/apps/server/src/routes/git/index.ts +++ b/apps/server/src/routes/git/index.ts @@ -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; } diff --git a/apps/server/src/routes/git/routes/stage-files.ts b/apps/server/src/routes/git/routes/stage-files.ts new file mode 100644 index 00000000..b618bdd3 --- /dev/null +++ b/apps/server/src/routes/git/routes/stage-files.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/setup/routes/verify-claude-auth.ts b/apps/server/src/routes/setup/routes/verify-claude-auth.ts index 041e534e..18a40bf8 100644 --- a/apps/server/src/routes/setup/routes/verify-claude-auth.ts +++ b/apps/server/src/routes/setup/routes/verify-claude-auth.ts @@ -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, diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 09b291de..190dde34 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -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; } diff --git a/apps/server/src/routes/worktree/routes/abort-operation.ts b/apps/server/src/routes/worktree/routes/abort-operation.ts new file mode 100644 index 00000000..297e2ac8 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/abort-operation.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/continue-operation.ts b/apps/server/src/routes/worktree/routes/continue-operation.ts new file mode 100644 index 00000000..e7582c02 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/continue-operation.ts @@ -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 { + 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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index 0f8021f1..19cbb850 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -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 { @@ -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 + } } } diff --git a/apps/server/src/routes/worktree/routes/stage-files.ts b/apps/server/src/routes/worktree/routes/stage-files.ts new file mode 100644 index 00000000..8dca59fb --- /dev/null +++ b/apps/server/src/routes/worktree/routes/stage-files.ts @@ -0,0 +1,69 @@ +/** + * POST /stage-files endpoint - Stage or unstage files in a worktree + * + * Supports two operations: + * 1. Stage files: `git add ` (adds files to the staging area) + * 2. Unstage files: `git reset HEAD -- ` (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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/stash-apply.ts b/apps/server/src/routes/worktree/routes/stash-apply.ts index 615905cb..f854edd3 100644 --- a/apps/server/src/routes/worktree/routes/stash-apply.ts +++ b/apps/server/src/routes/worktree/routes/stash-apply.ts @@ -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; } diff --git a/apps/server/src/services/agent-executor.ts b/apps/server/src/services/agent-executor.ts index 7a0967eb..3ed38da2 100644 --- a/apps/server/src/services/agent-executor.ts +++ b/apps/server/src/services/agent-executor.ts @@ -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(); diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index bae39db2..b1fec941 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -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` ); diff --git a/apps/server/src/services/auto-mode/facade.ts b/apps/server/src/services/auto-mode/facade.ts index 8e4b1d73..3a98c131 100644 --- a/apps/server/src/services/auto-mode/facade.ts +++ b/apps/server/src/services/auto-mode/facade.ts @@ -208,7 +208,7 @@ export class AutoModeServiceFacade { model?: string, opts?: Record ) => { - 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}`); diff --git a/apps/server/src/services/merge-service.ts b/apps/server/src/services/merge-service.ts index 9bc60da3..8e98530d 100644 --- a/apps/server/src/services/merge-service.ts +++ b/apps/server/src/services/merge-service.ts @@ -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('..') + ); } /** diff --git a/apps/server/src/services/pull-service.ts b/apps/server/src/services/pull-service.ts index f2acb0bf..fbaf6012 100644 --- a/apps/server/src/services/pull-service.ts +++ b/apps/server/src/services/pull-service.ts @@ -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 { 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 + ); } /** diff --git a/apps/server/src/services/stash-service.ts b/apps/server/src/services/stash-service.ts index 8a201333..829acc94 100644 --- a/apps/server/src/services/stash-service.ts +++ b/apps/server/src/services/stash-service.ts @@ -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 { + 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 { 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 */ export async function dropStash( worktreePath: string, - stashIndex: number + stashIndex: number, + events?: EventEmitter ): Promise { 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, diff --git a/apps/server/src/services/worktree-branch-service.ts b/apps/server/src/services/worktree-branch-service.ts index 9047def7..fe50da13 100644 --- a/apps/server/src/services/worktree-branch-service.ts +++ b/apps/server/src/services/worktree-branch-service.ts @@ -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 { 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 { */ async function stashChanges(cwd: string, message: string): Promise { 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, diff --git a/apps/server/tests/unit/lib/sdk-options.test.ts b/apps/server/tests/unit/lib/sdk-options.test.ts index 69d69794..f552efd9 100644 --- a/apps/server/tests/unit/lib/sdk-options.test.ts +++ b/apps/server/tests/unit/lib/sdk-options.test.ts @@ -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'); }); }); diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index a2ebd72a..833ab9ca 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -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', () => { diff --git a/apps/server/tests/unit/providers/opencode-provider.test.ts b/apps/server/tests/unit/providers/opencode-provider.test.ts index 641838ef..a3a0d726 100644 --- a/apps/server/tests/unit/providers/opencode-provider.test.ts +++ b/apps/server/tests/unit/providers/opencode-provider.test.ts @@ -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'); }); diff --git a/apps/server/tests/unit/providers/provider-factory.test.ts b/apps/server/tests/unit/providers/provider-factory.test.ts index b9aef928..f92c7256 100644 --- a/apps/server/tests/unit/providers/provider-factory.test.ts +++ b/apps/server/tests/unit/providers/provider-factory.test.ts @@ -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); }); diff --git a/apps/server/tests/unit/services/agent-executor.test.ts b/apps/server/tests/unit/services/agent-executor.test.ts index 98314488..09f12cf4 100644 --- a/apps/server/tests/unit/services/agent-executor.test.ts +++ b/apps/server/tests/unit/services/agent-executor.test.ts @@ -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', }; diff --git a/apps/server/tests/unit/services/agent-service.test.ts b/apps/server/tests/unit/services/agent-service.test.ts index 11e92097..96090d2b 100644 --- a/apps/server/tests/unit/services/agent-service.test.ts +++ b/apps/server/tests/unit/services/agent-service.test.ts @@ -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); diff --git a/apps/server/tests/unit/services/ideation-service.test.ts b/apps/server/tests/unit/services/ideation-service.test.ts index 1be24cbe..7004362a 100644 --- a/apps/server/tests/unit/services/ideation-service.test.ts +++ b/apps/server/tests/unit/services/ideation-service.test.ts @@ -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', })) ); diff --git a/apps/ui/src/components/ui/git-diff-panel.tsx b/apps/ui/src/components/ui/git-diff-panel.tsx index d6070113..461244bf 100644 --- a/apps/ui/src/components/ui/git-diff-panel.tsx +++ b/apps/ui/src/components/ui/git-diff-panel.tsx @@ -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 ( + + Staged + + ); + } + if (state === 'partial') { + return ( + + Partial + + ); + } + return ( + + Unstaged + + ); +} + 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 (
-
+ {enableStaging && stagingState && } {fileDiff.isNew && ( new @@ -322,8 +386,43 @@ function FileDiffSection({ )} {additions > 0 && +{additions}} {deletions > 0 && -{deletions}} + {enableStaging && onStage && onUnstage && ( +
+ {isStagingFile ? ( + + ) : stagingState === 'staged' || stagingState === 'partial' ? ( + + ) : ( + + )} +
+ )}
- +
{isExpanded && (
{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>(new Set()); + const [stagingInProgress, setStagingInProgress] = useState>(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(); + 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({ })()}
+ {enableStaging && stagingSummary && ( + <> + + + + )}
@@ -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 && } {getStatusDisplayName(file.status)} + {enableStaging && ( +
+ {stagingInProgress.has(file.path) ? ( + + ) : getStagingState(file) === 'staged' || + getStagingState(file) === 'partial' ? ( + + ) : ( + + )} +
+ )}
{file.status === '?' ? ( diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 34e82e40..a81afc55 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -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(null); + const [showBranchConflictDialog, setShowBranchConflictDialog] = useState(false); + // Backlog plan dialog state const [showPlanDialog, setShowPlanDialog] = useState(false); const [pendingBacklogPlan, setPendingBacklogPlan] = useState(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 ", 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 */} + + {/* Commit Worktree Dialog */} 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{' '} + + {(conflictData.info as BranchSwitchConflictInfo).previousBranch} + {' '} + to {branchName}. Local changes were + stashed before switching and reapplying them caused conflicts. + + ) : ( + <> + The branch switch to {branchName}{' '} + failed and restoring the previously stashed local changes resulted in merge conflicts. + + ); + + const title = isBranchSwitch + ? 'Branch Switch Conflicts Detected' + : 'Stash Restore Conflicts Detected'; + + return ( + + + + + + {title} + + +
+ {description} + + {!isBranchSwitch && + (conflictData.info as StashPopConflictInfo).stashPopConflictMessage && ( +
+ + + {(conflictData.info as StashPopConflictInfo).stashPopConflictMessage} + +
+ )} + +
+

+ Choose how to resolve: +

+
    +
  • + Resolve with AI — Creates a task to analyze and resolve + conflicts automatically +
  • +
  • + Resolve Manually — Leaves conflict markers in place for + you to edit directly +
  • +
+
+
+
+
+ + + + {onResolveWithAI && ( + + )} + +
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/dialogs/cherry-pick-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/cherry-pick-dialog.tsx index 0a16378a..972577c2 100644 --- a/apps/ui/src/components/views/board-view/dialogs/cherry-pick-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/cherry-pick-dialog.tsx @@ -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{' '} {selectedBranch} -
  • Resolve any merge conflicts
  • +
  • Resolve any cherry-pick conflicts
  • Ensure the code compiles and tests pass
  • diff --git a/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx index 6f56cc0a..a3c0f27a 100644 --- a/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx @@ -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 (
    @@ -583,6 +480,16 @@ export function CommitWorktreeDialog({ > {getStatusLabel(file.status)} + {isStaged && !isUntracked && ( + + Staged + + )} + {isStaged && isUnstaged && ( + + Partial + + )} {additions > 0 && ( +{additions} diff --git a/apps/ui/src/components/views/board-view/dialogs/discard-worktree-changes-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/discard-worktree-changes-dialog.tsx index bb1ad49c..d8788a3d 100644 --- a/apps/ui/src/components/views/board-view/dialogs/discard-worktree-changes-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/discard-worktree-changes-dialog.tsx @@ -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, diff --git a/apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx index 14a4d387..aad74048 100644 --- a/apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx @@ -76,17 +76,6 @@ export function GitPullDialog({ const [pullResult, setPullResult] = useState(null); const [errorMessage, setErrorMessage] = useState(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'} - {pullResult?.stashed && pullResult?.stashRestored && ( -
    - - - Your stashed changes have been restored successfully. - -
    - )} + {pullResult?.stashed && + pullResult?.stashRestored && + !pullResult?.stashRecoveryFailed && ( +
    + + + Your stashed changes have been restored successfully. + +
    + )} {pullResult?.stashed && (!pullResult?.stashRestored || pullResult?.stashRecoveryFailed) && ( diff --git a/apps/ui/src/components/views/board-view/dialogs/index.ts b/apps/ui/src/components/views/board-view/dialogs/index.ts index 22d2f3df..f4c5c252 100644 --- a/apps/ui/src/components/views/board-view/dialogs/index.ts +++ b/apps/ui/src/components/views/board-view/dialogs/index.ts @@ -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'; diff --git a/apps/ui/src/components/views/board-view/dialogs/merge-rebase-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/merge-rebase-dialog.tsx index 9aa1cf43..8a730080 100644 --- a/apps/ui/src/components/views/board-view/dialogs/merge-rebase-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/merge-rebase-dialog.tsx @@ -60,11 +60,6 @@ interface MergeRebaseDialogProps { open: boolean; onOpenChange: (open: boolean) => void; worktree: WorktreeInfo | null; - onConfirm: ( - worktree: WorktreeInfo, - remoteBranch: string, - strategy: PullStrategy - ) => void | Promise; onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void; } @@ -72,7 +67,6 @@ export function MergeRebaseDialog({ open, onOpenChange, worktree, - onConfirm, onCreateConflictResolutionFeature, }: MergeRebaseDialogProps) { const [remotes, setRemotes] = useState([]); @@ -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', { diff --git a/apps/ui/src/components/views/board-view/dialogs/stash-changes-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/stash-changes-dialog.tsx index 18f4e9fa..a5907354 100644 --- a/apps/ui/src/components/views/board-view/dialogs/stash-changes-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/stash-changes-dialog.tsx @@ -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({

    A descriptive message helps identify this stash later. Press{' '} - {navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+Enter + {typeof navigator !== 'undefined' && + ((navigator as any).userAgentData?.platform || navigator.platform || '').includes( + 'Mac' + ) + ? '⌘' + : 'Ctrl'} + +Enter {' '} to stash.

    diff --git a/apps/ui/src/components/views/board-view/dialogs/view-worktree-changes-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/view-worktree-changes-dialog.tsx index 12588d50..ac564caf 100644 --- a/apps/ui/src/components/views/board-view/dialogs/view-worktree-changes-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/view-worktree-changes-dialog.tsx @@ -48,6 +48,9 @@ export function ViewWorktreeChangesDialog({ {worktree.changedFilesCount > 1 ? 's' : ''} changed) )} + + — Use the Stage/Unstage buttons to prepare files for commit. + @@ -58,6 +61,8 @@ export function ViewWorktreeChangesDialog({ featureId={worktree.branch} useWorktrees={true} compact={false} + enableStaging={true} + worktreePath={worktree.path} className="mt-4" />
    diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index 2ea56be7..1de7893e 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -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({ + {/* Conflict indicator and actions when merge/rebase/cherry-pick is in progress */} + {worktree.hasConflicts && ( + <> + + + {worktree.conflictType === 'merge' + ? 'Merge' + : worktree.conflictType === 'rebase' + ? 'Rebase' + : worktree.conflictType === 'cherry-pick' + ? 'Cherry-pick' + : 'Operation'}{' '} + Conflicts + {worktree.conflictFiles && worktree.conflictFiles.length > 0 && ( + + {worktree.conflictFiles.length} file + {worktree.conflictFiles.length !== 1 ? 's' : ''} + + )} + + {onAbortOperation && ( + onAbortOperation(worktree)} + className="text-xs text-destructive focus:text-destructive" + > + + Abort{' '} + {worktree.conflictType === 'merge' + ? 'Merge' + : worktree.conflictType === 'rebase' + ? 'Rebase' + : worktree.conflictType === 'cherry-pick' + ? 'Cherry-pick' + : 'Operation'} + + )} + {onContinueOperation && ( + onContinueOperation(worktree)} + className="text-xs text-green-600 focus:text-green-700" + > + + Continue{' '} + {worktree.conflictType === 'merge' + ? 'Merge' + : worktree.conflictType === 'rebase' + ? 'Rebase' + : worktree.conflictType === 'cherry-pick' + ? 'Cherry-pick' + : 'Operation'} + + )} + + + )} {/* Loading indicator while git status is being determined */} {isLoadingGitStatus && ( <> diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown-item.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown-item.tsx index 2fb79aed..e23230cc 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown-item.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown-item.tsx @@ -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({ )} + {/* Conflict indicator */} + {worktree.hasConflicts && ( + + + {getConflictTypeLabel(worktree.conflictType)} + + )} + {/* PR indicator */} {pr && ( 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({ )} + {/* Conflict indicator */} + {selectedWorktree?.hasConflicts && ( + + + {getConflictTypeLabel(selectedWorktree.conflictType)} + + )} + {/* PR badge */} {selectedWorktree?.pr && ( )} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-indicator-utils.ts b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-indicator-utils.ts index 503a8396..2c67659c 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-indicator-utils.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-indicator-utils.ts @@ -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'; diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index 03d9585e..308753ed 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -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({ )} + {worktree.hasConflicts && ( + + + + + {getConflictTypeLabel(worktree.conflictType)} + + + +

    + {getConflictTypeLabel(worktree.conflictType)} conflicts detected + {worktree.conflictFiles && worktree.conflictFiles.length > 0 + ? ` (${worktree.conflictFiles.length} file${worktree.conflictFiles.length !== 1 ? 's' : ''})` + : ''} +

    +
    +
    + )} {prBadge} )} + {worktree.hasConflicts && ( + + + + + {getConflictTypeLabel(worktree.conflictType)} + + + +

    + {getConflictTypeLabel(worktree.conflictType)} conflicts detected + {worktree.conflictFiles && worktree.conflictFiles.length > 0 + ? ` (${worktree.conflictFiles.length} file${worktree.conflictFiles.length !== 1 ? 's' : ''})` + : ''} +

    +
    +
    + )} {prBadge} )} @@ -463,6 +516,8 @@ export function WorktreeTab({ onStashChanges={onStashChanges} onViewStashes={onViewStashes} onCherryPick={onCherryPick} + onAbortOperation={onAbortOperation} + onContinueOperation={onContinueOperation} hasInitScript={hasInitScript} /> diff --git a/apps/ui/src/components/views/board-view/worktree-panel/types.ts b/apps/ui/src/components/views/board-view/worktree-panel/types.ts index b2b00a10..31f6608f 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/types.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/types.ts @@ -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 { diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index 639e4c1b..833e5a19 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -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} /> diff --git a/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx b/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx index 969f2fb8..8630b1e5 100644 --- a/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx +++ b/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx @@ -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({ {enabledOpencodeModels.map((modelId) => { - const model = OPENCODE_MODEL_CONFIG_MAP[modelId]; + const model = effectiveModelConfigMap[modelId]; if (!model) return null; const ModelIconComponent = getModelIcon(modelId); return ( diff --git a/apps/ui/src/hooks/queries/use-worktrees.ts b/apps/ui/src/hooks/queries/use-worktrees.ts index 60fdcca9..381e715e 100644 --- a/apps/ui/src/hooks/queries/use-worktrees.ts +++ b/apps/ui/src/hooks/queries/use-worktrees.ts @@ -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 { diff --git a/apps/ui/src/lib/agent-context-parser.ts b/apps/ui/src/lib/agent-context-parser.ts index 11c2a91e..f5cfd869 100644 --- a/apps/ui/src/lib/agent-context-parser.ts +++ b/apps/ui/src/lib/agent-context-parser.ts @@ -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'; diff --git a/apps/ui/src/lib/diff-utils.ts b/apps/ui/src/lib/diff-utils.ts new file mode 100644 index 00000000..fd141b46 --- /dev/null +++ b/apps/ui/src/lib/diff-utils.ts @@ -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; +} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index c653ef73..c70c1062 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -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, + }, + }; + }, }; } diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index cc1243ce..1ce9bed3 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -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 => { @@ -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 diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 36b3a732..c2d6c946 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -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; + // 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; + + // 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 diff --git a/libs/git-utils/src/status.ts b/libs/git-utils/src/status.ts index 2a266f62..15b2f9bd 100644 --- a/libs/git-utils/src/status.ts +++ b/libs/git-utils/src/status.ts @@ -99,6 +99,8 @@ export function parseGitStatus(statusOutput: string): FileStatus[] { status: primaryStatus, path: filePath, statusText: getStatusText(indexStatus, workTreeStatus), + indexStatus, + workTreeStatus, }; }); } diff --git a/libs/git-utils/src/types.ts b/libs/git-utils/src/types.ts index debfd567..b6f16e66 100644 --- a/libs/git-utils/src/types.ts +++ b/libs/git-utils/src/types.ts @@ -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; } diff --git a/libs/model-resolver/src/resolver.ts b/libs/model-resolver/src/resolver.ts index ebb36c44..877fcafc 100644 --- a/libs/model-resolver/src/resolver.ts +++ b/libs/model-resolver/src/resolver.ts @@ -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; diff --git a/libs/model-resolver/tests/resolver.test.ts b/libs/model-resolver/tests/resolver.test.ts index 7b6af623..0a3fa0b4 100644 --- a/libs/model-resolver/tests/resolver.test.ts +++ b/libs/model-resolver/tests/resolver.test.ts @@ -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); diff --git a/libs/types/src/copilot-models.ts b/libs/types/src/copilot-models.ts index 21207133..a62f5672 100644 --- a/libs/types/src/copilot-models.ts +++ b/libs/types/src/copilot-models.ts @@ -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 diff --git a/libs/types/src/cursor-models.ts b/libs/types/src/cursor-models.ts index 08db74d8..a48a791f 100644 --- a/libs/types/src/cursor-models.ts +++ b/libs/types/src/cursor-models.ts @@ -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 = { 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 = { export const LEGACY_CURSOR_MODEL_MAP: Record = { 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', diff --git a/libs/types/src/model-display.ts b/libs/types/src/model-display.ts index 30688de7..8eead4ed 100644 --- a/libs/types/src/model-display.ts +++ b/libs/types/src/model-display.ts @@ -253,7 +253,7 @@ export const REASONING_EFFORT_LABELS: Record = { * ```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', diff --git a/libs/types/src/model-migration.ts b/libs/types/src/model-migration.ts index 49e28c8e..b42833f7 100644 --- a/libs/types/src/model-migration.ts +++ b/libs/types/src/model-migration.ts @@ -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 } /** diff --git a/libs/types/src/model.ts b/libs/types/src/model.ts index 94d49b94..16f72b20 100644 --- a/libs/types/src/model.ts +++ b/libs/types/src/model.ts @@ -17,7 +17,7 @@ export type ClaudeCanonicalId = 'claude-haiku' | 'claude-sonnet' | 'claude-opus' */ export const CLAUDE_CANONICAL_MAP: Record = { '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 = { */ export const CLAUDE_MODEL_MAP: Record = { haiku: 'claude-haiku-4-5-20251001', - sonnet: 'claude-sonnet-4-5-20250929', + sonnet: 'claude-sonnet-4-6', opus: 'claude-opus-4-6', } as const; diff --git a/libs/types/src/opencode-models.ts b/libs/types/src/opencode-models.ts index de96f96b..95c0c543 100644 --- a/libs/types/src/opencode-models.ts +++ b/libs/types/src/opencode-models.ts @@ -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 = { // 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 = { + // 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 = { + '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]; diff --git a/libs/types/src/provider-utils.ts b/libs/types/src/provider-utils.ts index 772d4d7c..9d8caa87 100644 --- a/libs/types/src/provider-utils.ts +++ b/libs/types/src/provider-utils.ts @@ -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