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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -76,17 +76,6 @@ export function GitPullDialog({
const [pullResult, setPullResult] = useState<PullResult | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Reset state when dialog opens
useEffect(() => {
if (open && worktree) {
setPhase('checking');
setPullResult(null);
setErrorMessage(null);
// Start the initial check
checkForLocalChanges();
}
}, [open, worktree]); // eslint-disable-line react-hooks/exhaustive-deps
const checkForLocalChanges = useCallback(async () => {
if (!worktree) return;
@@ -129,6 +118,17 @@ export function GitPullDialog({
}
}, [worktree, remote, onPulled]);
// Reset state when dialog opens
useEffect(() => {
if (open && worktree) {
setPhase('checking');
setPullResult(null);
setErrorMessage(null);
// Start the initial check
checkForLocalChanges();
}
}, [open, worktree, checkForLocalChanges]);
const handlePullWithStash = useCallback(async () => {
if (!worktree) return;
@@ -154,9 +154,14 @@ export function GitPullDialog({
if (result.result?.hasConflicts) {
setPhase('conflict');
} else {
} else if (result.result?.pulled) {
setPhase('success');
onPulled?.();
} else {
// Unrecognized response: no pulled flag and no conflicts
console.warn('handlePullWithStash: unrecognized response', result.result);
setErrorMessage('Unexpected pull response');
setPhase('error');
}
} catch (err) {
setErrorMessage(err instanceof Error ? err.message : 'Failed to pull');
@@ -300,14 +305,16 @@ export function GitPullDialog({
{pullResult?.message || 'Changes pulled successfully'}
</span>
{pullResult?.stashed && pullResult?.stashRestored && (
<div className="flex items-start gap-2 p-3 rounded-md bg-green-500/10 border border-green-500/20">
<Archive className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
<span className="text-green-600 dark:text-green-400 text-sm">
Your stashed changes have been restored successfully.
</span>
</div>
)}
{pullResult?.stashed &&
pullResult?.stashRestored &&
!pullResult?.stashRecoveryFailed && (
<div className="flex items-start gap-2 p-3 rounded-md bg-green-500/10 border border-green-500/20">
<Archive className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
<span className="text-green-600 dark:text-green-400 text-sm">
Your stashed changes have been restored successfully.
</span>
</div>
)}
{pullResult?.stashed &&
(!pullResult?.stashRestored || pullResult?.stashRecoveryFailed) && (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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