US-709: Add merge conflict resolution UI

- ConflictService: detect, list, resolve conflicts
- Parse conflict markers, get ours/theirs/base versions
- API endpoints: conflicts status, file details, resolve, abort, continue
- ConflictResolver slide-out panel with:
  - File list sidebar
  - Side-by-side comparison (Ours vs Theirs)
  - Manual edit tab
  - Accept ours/theirs/manual resolution
  - Abort and continue actions
- Conflict warning banner in GitSection
This commit is contained in:
Brad Groux 2026-01-26 06:22:08 -06:00
parent edc23f700b
commit 8facd82b04
7 changed files with 1003 additions and 3 deletions

View file

@ -19,7 +19,7 @@
| US-706 | Auto-archive suggestions | ✅ Done | US-701, US-703 | Suggest archiving when project complete |
| US-707 | GitHub PR creation | ✅ Done | None | Create PR from task detail UI |
| US-708 | Preview mode | ✅ Done | None | Embedded browser for dev server preview |
| US-709 | Merge conflict resolution | ⏳ Todo | None | Visual conflict resolver UI |
| US-709 | Merge conflict resolution | ✅ Done | None | Visual conflict resolver UI |
| US-710 | Time tracking | ⏳ Todo | None | Start/stop timer, manual entry, reports |
| US-711 | Running indicator on cards | ⏳ Todo | None | Spinner/pulse animation when agent running |
@ -145,4 +145,24 @@
- Activity logging for project_archived events
- Replaces client-side ProjectArchiveSuggestion with server-side approach
**US-709: Merge conflict resolution** ✅
- ConflictService for detecting and resolving git conflicts:
- Detect rebase/merge in progress
- List conflicting files
- Get file conflict details (ours, theirs, base versions)
- Parse conflict markers
- Resolve with ours/theirs/manual content
- Abort or continue rebase/merge
- API endpoints: GET /api/conflicts/:taskId, file, resolve, abort, continue
- ConflictResolver component:
- Slide-out panel (90vw, max 1200px)
- File list sidebar with conflict count
- Side-by-side comparison view (Ours vs Theirs)
- Manual edit tab with full content editor
- Accept Ours / Accept Theirs buttons
- Abort and Continue actions with confirmation
- File navigation (prev/next)
- Conflict warning banner in GitSection when conflicts detected
- Auto-polling when conflicts present
(Starting Sprint 7)

View file

@ -13,6 +13,7 @@ import templateRoutes from './routes/templates.js';
import activityRoutes from './routes/activity.js';
import githubRoutes from './routes/github.js';
import previewRoutes from './routes/preview.js';
import conflictRoutes from './routes/conflicts.js';
import type { AgentOutput } from './services/agent-service.js';
const app = express();
@ -39,6 +40,7 @@ app.use('/api/templates', templateRoutes);
app.use('/api/activity', activityRoutes);
app.use('/api/github', githubRoutes);
app.use('/api/preview', previewRoutes);
app.use('/api/conflicts', conflictRoutes);
// Create HTTP server
const server = createServer(app);

View file

@ -0,0 +1,107 @@
import { Router, type Router as RouterType } from 'express';
import { z } from 'zod';
import { ConflictService } from '../services/conflict-service.js';
const router: RouterType = Router();
const conflictService = new ConflictService();
// Validation schemas
const resolveSchema = z.object({
resolution: z.enum(['ours', 'theirs', 'manual']),
manualContent: z.string().optional(),
});
// GET /api/conflicts/:taskId - Get conflict status for a task
router.get('/:taskId', async (req, res) => {
try {
const status = await conflictService.getConflictStatus(req.params.taskId);
res.json(status);
} catch (error: any) {
console.error('Error getting conflict status:', error);
res.status(500).json({ error: error.message || 'Failed to get conflict status' });
}
});
// GET /api/conflicts/:taskId/file - Get conflict details for a specific file
router.get('/:taskId/file', async (req, res) => {
try {
const filePath = req.query.path as string;
if (!filePath) {
return res.status(400).json({ error: 'File path required' });
}
const conflict = await conflictService.getFileConflict(req.params.taskId, filePath);
res.json(conflict);
} catch (error: any) {
console.error('Error getting file conflict:', error);
res.status(500).json({ error: error.message || 'Failed to get file conflict' });
}
});
// POST /api/conflicts/:taskId/resolve - Resolve a file conflict
router.post('/:taskId/resolve', async (req, res) => {
try {
const filePath = req.query.path as string;
if (!filePath) {
return res.status(400).json({ error: 'File path required' });
}
const input = resolveSchema.parse(req.body);
const result = await conflictService.resolveFile(
req.params.taskId,
filePath,
input.resolution,
input.manualContent
);
res.json(result);
} catch (error: any) {
if (error instanceof z.ZodError) {
return res.status(400).json({ error: 'Validation failed', details: error.errors });
}
console.error('Error resolving conflict:', error);
res.status(500).json({ error: error.message || 'Failed to resolve conflict' });
}
});
// POST /api/conflicts/:taskId/abort - Abort rebase or merge
router.post('/:taskId/abort', async (req, res) => {
try {
const status = await conflictService.getConflictStatus(req.params.taskId);
if (status.rebaseInProgress) {
await conflictService.abortRebase(req.params.taskId);
} else if (status.mergeInProgress) {
await conflictService.abortMerge(req.params.taskId);
} else {
return res.status(400).json({ error: 'No rebase or merge in progress' });
}
res.json({ success: true });
} catch (error: any) {
console.error('Error aborting:', error);
res.status(500).json({ error: error.message || 'Failed to abort' });
}
});
// POST /api/conflicts/:taskId/continue - Continue rebase or merge
router.post('/:taskId/continue', async (req, res) => {
try {
const status = await conflictService.getConflictStatus(req.params.taskId);
let result;
if (status.rebaseInProgress) {
result = await conflictService.continueRebase(req.params.taskId);
} else if (status.mergeInProgress) {
result = await conflictService.continueMerge(req.params.taskId, req.body.message);
} else {
return res.status(400).json({ error: 'No rebase or merge in progress' });
}
res.json(result);
} catch (error: any) {
console.error('Error continuing:', error);
res.status(500).json({ error: error.message || 'Failed to continue' });
}
});
export default router;

View file

@ -0,0 +1,288 @@
import { simpleGit, SimpleGit } from 'simple-git';
import fs from 'fs/promises';
import path from 'path';
import { ConfigService } from './config-service.js';
import { TaskService } from './task-service.js';
export interface ConflictFile {
path: string;
content: string;
oursContent: string;
theirsContent: string;
baseContent: string;
markers: ConflictMarker[];
}
export interface ConflictMarker {
startLine: number;
separatorLine: number;
endLine: number;
oursLines: string[];
theirsLines: string[];
}
export interface ConflictStatus {
hasConflicts: boolean;
conflictingFiles: string[];
rebaseInProgress: boolean;
mergeInProgress: boolean;
}
export interface ResolveResult {
success: boolean;
remainingConflicts: string[];
}
export class ConflictService {
private configService: ConfigService;
private taskService: TaskService;
constructor() {
this.configService = new ConfigService();
this.taskService = new TaskService();
}
private expandPath(p: string): string {
return p.replace(/^~/, process.env.HOME || '');
}
/**
* Get the working directory for a task (worktree or repo)
*/
private async getWorkingDir(taskId: string): Promise<{ git: SimpleGit; workDir: string }> {
const task = await this.taskService.getTask(taskId);
if (!task?.git?.repo) {
throw new Error('Task must have a repository configured');
}
const config = await this.configService.getConfig();
const repoConfig = config.repos.find(r => r.name === task.git!.repo);
if (!repoConfig) {
throw new Error(`Repository "${task.git.repo}" not found`);
}
const workDir = task.git.worktreePath || this.expandPath(repoConfig.path);
const git = simpleGit(workDir);
return { git, workDir };
}
/**
* Check if there are conflicts in the working directory
*/
async getConflictStatus(taskId: string): Promise<ConflictStatus> {
const { git, workDir } = await this.getWorkingDir(taskId);
// Check for rebase in progress
const rebaseDir = path.join(workDir, '.git', 'rebase-merge');
const rebaseApplyDir = path.join(workDir, '.git', 'rebase-apply');
const rebaseInProgress = await fs.access(rebaseDir).then(() => true).catch(() => false) ||
await fs.access(rebaseApplyDir).then(() => true).catch(() => false);
// Check for merge in progress
const mergeHead = path.join(workDir, '.git', 'MERGE_HEAD');
const mergeInProgress = await fs.access(mergeHead).then(() => true).catch(() => false);
// Get status to find conflicted files
const status = await git.status();
const conflictingFiles = status.conflicted || [];
return {
hasConflicts: conflictingFiles.length > 0,
conflictingFiles,
rebaseInProgress,
mergeInProgress,
};
}
/**
* Get detailed conflict information for a file
*/
async getFileConflict(taskId: string, filePath: string): Promise<ConflictFile> {
const { git, workDir } = await this.getWorkingDir(taskId);
const fullPath = path.join(workDir, filePath);
const content = await fs.readFile(fullPath, 'utf-8');
// Parse conflict markers
const markers = this.parseConflictMarkers(content);
// Get the different versions
let oursContent = '';
let theirsContent = '';
let baseContent = '';
try {
// Get our version (HEAD)
oursContent = await git.show([`:2:${filePath}`]);
} catch {
// File might be new on our side
}
try {
// Get their version (incoming)
theirsContent = await git.show([`:3:${filePath}`]);
} catch {
// File might be new on their side
}
try {
// Get base version (common ancestor)
baseContent = await git.show([`:1:${filePath}`]);
} catch {
// File might not have a common ancestor
}
return {
path: filePath,
content,
oursContent,
theirsContent,
baseContent,
markers,
};
}
/**
* Parse conflict markers from file content
*/
private parseConflictMarkers(content: string): ConflictMarker[] {
const lines = content.split('\n');
const markers: ConflictMarker[] = [];
let i = 0;
while (i < lines.length) {
if (lines[i].startsWith('<<<<<<<')) {
const startLine = i;
const oursLines: string[] = [];
const theirsLines: string[] = [];
// Collect "ours" lines
i++;
while (i < lines.length && !lines[i].startsWith('=======')) {
oursLines.push(lines[i]);
i++;
}
const separatorLine = i;
// Collect "theirs" lines
i++;
while (i < lines.length && !lines[i].startsWith('>>>>>>>')) {
theirsLines.push(lines[i]);
i++;
}
const endLine = i;
markers.push({
startLine,
separatorLine,
endLine,
oursLines,
theirsLines,
});
}
i++;
}
return markers;
}
/**
* Resolve a file conflict by choosing a side
*/
async resolveFile(
taskId: string,
filePath: string,
resolution: 'ours' | 'theirs' | 'manual',
manualContent?: string
): Promise<ResolveResult> {
const { git, workDir } = await this.getWorkingDir(taskId);
const fullPath = path.join(workDir, filePath);
if (resolution === 'manual') {
if (!manualContent) {
throw new Error('Manual content required for manual resolution');
}
await fs.writeFile(fullPath, manualContent, 'utf-8');
} else if (resolution === 'ours') {
// Checkout our version
await git.checkout(['--ours', filePath]);
} else if (resolution === 'theirs') {
// Checkout their version
await git.checkout(['--theirs', filePath]);
}
// Stage the resolved file
await git.add(filePath);
// Check remaining conflicts
const status = await this.getConflictStatus(taskId);
return {
success: true,
remainingConflicts: status.conflictingFiles,
};
}
/**
* Abort the current rebase
*/
async abortRebase(taskId: string): Promise<void> {
const { git } = await this.getWorkingDir(taskId);
await git.rebase(['--abort']);
}
/**
* Continue rebase after conflicts are resolved
*/
async continueRebase(taskId: string): Promise<{ success: boolean; error?: string }> {
const { git } = await this.getWorkingDir(taskId);
try {
await git.rebase(['--continue']);
return { success: true };
} catch (error: any) {
// Check if there are still conflicts
const status = await this.getConflictStatus(taskId);
if (status.hasConflicts) {
return {
success: false,
error: `Still have ${status.conflictingFiles.length} conflicting file(s)`
};
}
return { success: false, error: error.message };
}
}
/**
* Abort the current merge
*/
async abortMerge(taskId: string): Promise<void> {
const { git } = await this.getWorkingDir(taskId);
await git.merge(['--abort']);
}
/**
* Continue/finish merge after conflicts are resolved
*/
async continueMerge(taskId: string, commitMessage?: string): Promise<{ success: boolean; error?: string }> {
const { git } = await this.getWorkingDir(taskId);
try {
// Commit the merge
await git.commit(commitMessage || 'Merge conflict resolution');
return { success: true };
} catch (error: any) {
const status = await this.getConflictStatus(taskId);
if (status.hasConflicts) {
return {
success: false,
error: `Still have ${status.conflictingFiles.length} conflicting file(s)`
};
}
return { success: false, error: error.message };
}
}
}

View file

@ -0,0 +1,376 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
useConflictStatus,
useFileConflict,
useResolveConflict,
useAbortConflict,
useContinueConflict,
} from '@/hooks/useConflicts';
import {
AlertTriangle,
Check,
X,
ChevronLeft,
ChevronRight,
FileWarning,
Loader2,
ArrowLeft,
ArrowRight,
GitMerge,
} from 'lucide-react';
import type { Task } from '@veritas-kanban/shared';
import { cn } from '@/lib/utils';
interface ConflictResolverProps {
task: Task;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ConflictResolver({ task, open, onOpenChange }: ConflictResolverProps) {
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [manualContent, setManualContent] = useState('');
const [showAbortDialog, setShowAbortDialog] = useState(false);
const { data: status, isLoading: statusLoading } = useConflictStatus(open ? task.id : undefined);
const { data: fileConflict, isLoading: fileLoading } = useFileConflict(
open && selectedFile ? task.id : undefined,
selectedFile || undefined
);
const resolveConflict = useResolveConflict();
const abortConflict = useAbortConflict();
const continueConflict = useContinueConflict();
// Auto-select first file if none selected
if (status?.conflictingFiles.length && !selectedFile) {
setSelectedFile(status.conflictingFiles[0]);
}
const handleResolve = async (resolution: 'ours' | 'theirs' | 'manual') => {
if (!selectedFile) return;
await resolveConflict.mutateAsync({
taskId: task.id,
filePath: selectedFile,
resolution,
manualContent: resolution === 'manual' ? manualContent : undefined,
});
// Move to next file or close if done
const remaining = status?.conflictingFiles.filter(f => f !== selectedFile) || [];
if (remaining.length > 0) {
setSelectedFile(remaining[0]);
} else {
setSelectedFile(null);
}
};
const handleAbort = async () => {
await abortConflict.mutateAsync(task.id);
setShowAbortDialog(false);
onOpenChange(false);
};
const handleContinue = async () => {
const result = await continueConflict.mutateAsync({ taskId: task.id });
if (result.success) {
onOpenChange(false);
}
};
const currentIndex = selectedFile && status?.conflictingFiles
? status.conflictingFiles.indexOf(selectedFile)
: -1;
const navigateFile = (direction: 'prev' | 'next') => {
if (!status?.conflictingFiles.length) return;
let newIndex = currentIndex;
if (direction === 'prev') {
newIndex = currentIndex > 0 ? currentIndex - 1 : status.conflictingFiles.length - 1;
} else {
newIndex = currentIndex < status.conflictingFiles.length - 1 ? currentIndex + 1 : 0;
}
setSelectedFile(status.conflictingFiles[newIndex]);
};
// Initialize manual content when file changes
if (fileConflict && manualContent !== fileConflict.content) {
setManualContent(fileConflict.content);
}
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-[90vw] sm:max-w-[1200px] p-0 flex flex-col">
<SheetHeader className="px-6 py-4 border-b">
<div className="flex items-center justify-between">
<div>
<SheetTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-amber-500" />
Merge Conflicts
</SheetTitle>
<SheetDescription>
{status?.rebaseInProgress ? 'Rebase' : 'Merge'} has conflicts that need to be resolved
</SheetDescription>
</div>
<div className="flex items-center gap-2">
{status?.conflictingFiles.length === 0 && (
<Button onClick={handleContinue} disabled={continueConflict.isPending}>
{continueConflict.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<GitMerge className="h-4 w-4 mr-2" />
)}
Continue {status?.rebaseInProgress ? 'Rebase' : 'Merge'}
</Button>
)}
<Button variant="outline" onClick={() => setShowAbortDialog(true)}>
<X className="h-4 w-4 mr-2" />
Abort
</Button>
</div>
</div>
</SheetHeader>
<div className="flex flex-1 overflow-hidden">
{/* File list sidebar */}
<div className="w-64 border-r flex flex-col">
<div className="p-3 border-b bg-muted/50">
<h3 className="text-sm font-medium">
Conflicting Files ({status?.conflictingFiles.length || 0})
</h3>
</div>
<ScrollArea className="flex-1">
{statusLoading ? (
<div className="p-4 text-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mx-auto mb-2" />
Loading...
</div>
) : status?.conflictingFiles.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
<Check className="h-8 w-8 mx-auto mb-2 text-green-500" />
All conflicts resolved!
</div>
) : (
<div className="p-2">
{status?.conflictingFiles.map((file) => (
<button
key={file}
onClick={() => setSelectedFile(file)}
className={cn(
"w-full text-left px-3 py-2 rounded-md text-sm truncate",
"hover:bg-muted transition-colors",
selectedFile === file && "bg-muted font-medium"
)}
>
<FileWarning className="h-3 w-3 inline mr-2 text-amber-500" />
{file.split('/').pop()}
<span className="text-xs text-muted-foreground block truncate pl-5">
{file}
</span>
</button>
))}
</div>
)}
</ScrollArea>
</div>
{/* Main content area */}
<div className="flex-1 flex flex-col overflow-hidden">
{selectedFile && fileConflict ? (
<>
{/* File header with navigation */}
<div className="px-4 py-2 border-b bg-muted/30 flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => navigateFile('prev')}
disabled={!status?.conflictingFiles.length}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm">
{currentIndex + 1} of {status?.conflictingFiles.length}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => navigateFile('next')}
disabled={!status?.conflictingFiles.length}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<code className="text-sm bg-muted px-2 py-1 rounded">
{selectedFile}
</code>
</div>
{/* Conflict viewer tabs */}
<Tabs defaultValue="sidebyside" className="flex-1 flex flex-col overflow-hidden">
<TabsList className="mx-4 mt-2 w-fit">
<TabsTrigger value="sidebyside">Side by Side</TabsTrigger>
<TabsTrigger value="manual">Manual Edit</TabsTrigger>
</TabsList>
{/* Side by side view */}
<TabsContent value="sidebyside" className="flex-1 overflow-hidden m-0 p-4">
<div className="grid grid-cols-2 gap-4 h-full">
{/* Ours */}
<div className="flex flex-col border rounded-lg overflow-hidden">
<div className="px-3 py-2 bg-blue-500/10 border-b flex items-center justify-between">
<span className="text-sm font-medium flex items-center gap-2">
<ArrowLeft className="h-4 w-4" />
Ours (Current)
</span>
<Button
size="sm"
variant="outline"
onClick={() => handleResolve('ours')}
disabled={resolveConflict.isPending}
>
{resolveConflict.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<>
<Check className="h-3 w-3 mr-1" />
Accept Ours
</>
)}
</Button>
</div>
<ScrollArea className="flex-1">
<pre className="p-3 text-xs font-mono whitespace-pre-wrap">
{fileConflict.oursContent || '(empty)'}
</pre>
</ScrollArea>
</div>
{/* Theirs */}
<div className="flex flex-col border rounded-lg overflow-hidden">
<div className="px-3 py-2 bg-green-500/10 border-b flex items-center justify-between">
<span className="text-sm font-medium flex items-center gap-2">
<ArrowRight className="h-4 w-4" />
Theirs (Incoming)
</span>
<Button
size="sm"
variant="outline"
onClick={() => handleResolve('theirs')}
disabled={resolveConflict.isPending}
>
{resolveConflict.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<>
<Check className="h-3 w-3 mr-1" />
Accept Theirs
</>
)}
</Button>
</div>
<ScrollArea className="flex-1">
<pre className="p-3 text-xs font-mono whitespace-pre-wrap">
{fileConflict.theirsContent || '(empty)'}
</pre>
</ScrollArea>
</div>
</div>
</TabsContent>
{/* Manual edit view */}
<TabsContent value="manual" className="flex-1 overflow-hidden m-0 p-4 flex flex-col">
<div className="flex-1 flex flex-col border rounded-lg overflow-hidden">
<div className="px-3 py-2 bg-muted/50 border-b flex items-center justify-between">
<span className="text-sm font-medium">
Manual Resolution
</span>
<Button
size="sm"
onClick={() => handleResolve('manual')}
disabled={resolveConflict.isPending}
>
{resolveConflict.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Check className="h-4 w-4 mr-2" />
)}
Save Resolution
</Button>
</div>
<Textarea
value={manualContent}
onChange={(e) => setManualContent(e.target.value)}
className="flex-1 font-mono text-xs resize-none border-0 rounded-none focus-visible:ring-0"
placeholder="Edit the file content to resolve conflicts..."
/>
</div>
</TabsContent>
</Tabs>
</>
) : fileLoading ? (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Select a file to resolve conflicts
</div>
)}
</div>
</div>
</SheetContent>
</Sheet>
{/* Abort confirmation dialog */}
<AlertDialog open={showAbortDialog} onOpenChange={setShowAbortDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Abort {status?.rebaseInProgress ? 'Rebase' : 'Merge'}?</AlertDialogTitle>
<AlertDialogDescription>
This will discard all conflict resolutions and return to the state before the
{status?.rebaseInProgress ? ' rebase' : ' merge'} started.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleAbort}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{abortConflict.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : null}
Abort
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -40,6 +40,8 @@ import {
useMergeWorktree
} from '@/hooks/useWorktree';
import { useCreatePR, useGitHubStatus } from '@/hooks/useGitHub';
import { useConflictStatus } from '@/hooks/useConflicts';
import { ConflictResolver } from './ConflictResolver';
import {
GitBranch,
FolderGit2,
@ -54,6 +56,7 @@ import {
ArrowUp,
ArrowDown,
GitPullRequest,
AlertTriangle,
} from 'lucide-react';
import type { Task, TaskGit } from '@veritas-kanban/shared';
import { cn } from '@/lib/utils';
@ -76,6 +79,7 @@ function WorktreeStatus({ task }: { task: Task }) {
const hasPR = !!task.git?.prUrl;
const { data: status, isLoading, error } = useWorktreeStatus(task.id, hasWorktree);
const { data: ghStatus } = useGitHubStatus();
const { data: conflictStatus } = useConflictStatus(hasWorktree ? task.id : undefined);
const createWorktree = useCreateWorktree();
const deleteWorktree = useDeleteWorktree();
@ -88,6 +92,9 @@ function WorktreeStatus({ task }: { task: Task }) {
const [prTitle, setPrTitle] = useState(task.title);
const [prBody, setPrBody] = useState(task.description || '');
const [prDraft, setPrDraft] = useState(false);
// Conflict resolver state
const [conflictResolverOpen, setConflictResolverOpen] = useState(false);
const handleOpenInVSCode = () => {
if (task.git?.worktreePath) {
@ -175,11 +182,42 @@ function WorktreeStatus({ task }: { task: Task }) {
// Show worktree status
return (
<div className="mt-3 pt-3 border-t space-y-3">
{/* Conflict warning banner */}
{conflictStatus?.hasConflicts && (
<div className="flex items-center justify-between gap-3 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">
{conflictStatus.conflictingFiles.length} conflict{conflictStatus.conflictingFiles.length !== 1 ? 's' : ''} detected
</p>
<p className="text-xs text-amber-600 dark:text-amber-500">
{conflictStatus.rebaseInProgress ? 'Rebase' : 'Merge'} requires manual resolution
</p>
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={() => setConflictResolverOpen(true)}
className="border-amber-500/30 hover:bg-amber-500/10"
>
<AlertTriangle className="h-4 w-4 mr-1" />
Resolve Conflicts
</Button>
</div>
)}
{/* Status indicators */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-muted-foreground">Worktree active</span>
<span className={cn(
"h-2 w-2 rounded-full",
conflictStatus?.hasConflicts ? "bg-amber-500" : "bg-green-500"
)} />
<span className="text-muted-foreground">
{conflictStatus?.hasConflicts ? 'Conflicts detected' : 'Worktree active'}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{status && (
@ -382,6 +420,13 @@ function WorktreeStatus({ task }: { task: Task }) {
<div className="text-xs text-muted-foreground font-mono truncate">
{task.git.worktreePath}
</div>
{/* Conflict Resolver */}
<ConflictResolver
task={task}
open={conflictResolverOpen}
onOpenChange={setConflictResolverOpen}
/>
</div>
);
}

View file

@ -0,0 +1,162 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
const API_BASE = '/api';
export interface ConflictStatus {
hasConflicts: boolean;
conflictingFiles: string[];
rebaseInProgress: boolean;
mergeInProgress: boolean;
}
export interface ConflictMarker {
startLine: number;
separatorLine: number;
endLine: number;
oursLines: string[];
theirsLines: string[];
}
export interface ConflictFile {
path: string;
content: string;
oursContent: string;
theirsContent: string;
baseContent: string;
markers: ConflictMarker[];
}
export interface ResolveResult {
success: boolean;
remainingConflicts: string[];
}
/**
* Get conflict status for a task
*/
export function useConflictStatus(taskId: string | undefined) {
return useQuery<ConflictStatus>({
queryKey: ['conflicts', taskId],
queryFn: async () => {
if (!taskId) return { hasConflicts: false, conflictingFiles: [], rebaseInProgress: false, mergeInProgress: false };
const response = await fetch(`${API_BASE}/conflicts/${taskId}`);
if (!response.ok) {
throw new Error('Failed to get conflict status');
}
return response.json();
},
enabled: !!taskId,
refetchInterval: (query) => {
// Poll when there are conflicts
if (query.state.data?.hasConflicts) {
return 5000;
}
return false;
},
});
}
/**
* Get conflict details for a specific file
*/
export function useFileConflict(taskId: string | undefined, filePath: string | undefined) {
return useQuery<ConflictFile>({
queryKey: ['conflicts', taskId, 'file', filePath],
queryFn: async () => {
if (!taskId || !filePath) throw new Error('Task ID and file path required');
const response = await fetch(`${API_BASE}/conflicts/${taskId}/file?path=${encodeURIComponent(filePath)}`);
if (!response.ok) {
throw new Error('Failed to get file conflict');
}
return response.json();
},
enabled: !!taskId && !!filePath,
});
}
/**
* Resolve a file conflict
*/
export function useResolveConflict() {
const queryClient = useQueryClient();
return useMutation<ResolveResult, Error, {
taskId: string;
filePath: string;
resolution: 'ours' | 'theirs' | 'manual';
manualContent?: string;
}>({
mutationFn: async ({ taskId, filePath, resolution, manualContent }) => {
const response = await fetch(`${API_BASE}/conflicts/${taskId}/resolve?path=${encodeURIComponent(filePath)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resolution, manualContent }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to resolve conflict');
}
return response.json();
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['conflicts', variables.taskId] });
queryClient.invalidateQueries({ queryKey: ['conflicts', variables.taskId, 'file'] });
},
});
}
/**
* Abort rebase or merge
*/
export function useAbortConflict() {
const queryClient = useQueryClient();
return useMutation<{ success: boolean }, Error, string>({
mutationFn: async (taskId) => {
const response = await fetch(`${API_BASE}/conflicts/${taskId}/abort`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to abort');
}
return response.json();
},
onSuccess: (_data, taskId) => {
queryClient.invalidateQueries({ queryKey: ['conflicts', taskId] });
queryClient.invalidateQueries({ queryKey: ['worktree', taskId] });
},
});
}
/**
* Continue rebase or merge after resolving conflicts
*/
export function useContinueConflict() {
const queryClient = useQueryClient();
return useMutation<{ success: boolean; error?: string }, Error, { taskId: string; message?: string }>({
mutationFn: async ({ taskId, message }) => {
const response = await fetch(`${API_BASE}/conflicts/${taskId}/continue`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to continue');
}
return response.json();
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['conflicts', variables.taskId] });
queryClient.invalidateQueries({ queryKey: ['worktree', variables.taskId] });
},
});
}