38 KiB
Layer C — Inline Conflict Resolver Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the worker-side conflict plumbing (5 frozen hub methods + GitService reads) and a VSCode-style in-app inline conflict resolver UI for ClaudeDo's merge rework.
Architecture: The worker performs a real merge that leaves conflicts in the list's working tree (leaveConflictsInTree:true), exposes ours/theirs/base per conflicted file via git show :2:/:3:/:1:, accepts written resolutions, and finishes via the existing ContinueMergeAsync/AbortMergeAsync. The UI presents each conflicted file's hunk with Accept Current/Incoming/Both/Edit-manually controls plus a free-text merged box, then writes resolutions and continues.
Tech Stack: .NET 8, ASP.NET Core SignalR (WorkerHub), EF Core/SQLite, Avalonia MVVM (CommunityToolkit), xUnit + real git/SQLite fixtures.
Frozen client contract (already shipped in foundation commit 2dfc455, DO NOT edit):
IWorkerClient/WorkerClient.csalready call hub methods by name:StartConflictMerge,GetMergeConflicts,WriteConflictResolution,ContinueMerge,AbortMerge.- Client DTOs already exist in
WorkerClient.cs:MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files),ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks),ConflictHunkDto(string Ours, string Theirs, string? Base), plus existingMergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage). - Worker-side DTOs must serialize identically (same record shape) and live in
WorkerHub.cs.
Do NOT touch: WorkerClient.cs, Interfaces/IWorkerClient.cs, WorkConsole.axaml, DetailsIslandViewModel.cs, WorktreesOverviewModalView/VM, WorktreeModalView. Test fakes for IWorkerClient already implement the 5 methods as no-op stubs (StubWorkerClient is virtual in Ui.Tests) — subclass/override, never edit the interface.
Build/test commands (.NET 8 — running Worker locks Debug, always -c Release):
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
File Structure
Worker / Data (create + modify):
- Modify
src/ClaudeDo.Data/Git/GitService.cs— addShowStageAsync(untrimmed blob read) +AddPathAsync; addtrimOutputparam toRunGitAsync. - Modify
src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs— add recordsMergeConflicts/ConflictFileContent; addGetConflictsAsync+WriteResolutionAsync. - Modify
src/ClaudeDo.Worker/Hub/WorkerHub.cs— add DTOsMergeConflictsDto/ConflictFileDto/ConflictHunkDto+ 5 hub methods.
UI (create new only):
- Create
src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs—ConflictFile,ConflictHunk. - Create
src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs. - Create
src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml+.axaml.cs.
Wiring (modify):
- Modify
src/ClaudeDo.App/Program.cs— registerConflictResolverViewModel+Func<string, ConflictResolverViewModel>. - Modify
src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs— additive seam (ConflictResolverFactory,ShowConflictResolver,RequestConflictResolutionAsync). - Modify
src/ClaudeDo.Ui/Views/MainWindow.axaml.cs— wireShowConflictResolverdialog delegate. - Modify
src/ClaudeDo.Localization/locales/en.json+de.json—conflictResolver.*keys (parity enforced by Localization.Tests).
Tests (create + modify):
- Modify
tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs— conflict-read / write-resolution / round-trip tests. - Create
tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs.
Task 1: GitService conflict-blob reads
Files:
-
Modify:
src/ClaudeDo.Data/Git/GitService.cs -
Test:
tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs(GitService exercised here via real repo; add focused tests in Task 2 round-trip) -
Step 1: Add
trimOutputparam toRunGitAsyncso blob reads keep exact bytes.
In RunGitAsync signature add bool trimOutput = true, and change the return to:
return (proc.ExitCode, trimOutput ? stdout.TrimEnd() : stdout, stderr.TrimEnd());
(All existing callers keep the default true.)
- Step 2: Add
ShowStageAsync+AddPathAsync(place afterListConflictedFilesAsync):
/// <summary>
/// Reads a conflicted file's blob at a merge stage: 1=base, 2=ours, 3=theirs.
/// Returns null when the stage doesn't exist (e.g. add/add conflict has no base).
/// Output is NOT trimmed so file content round-trips exactly.
/// </summary>
public async Task<string?> ShowStageAsync(string repoDir, int stage, string path, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["show", $":{stage}:{path}"], ct, trimOutput: false);
return exitCode == 0 ? stdout : null;
}
public async Task AddPathAsync(string repoDir, string path, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["add", "--", path], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git add '{path}' failed (exit {exitCode}): {stderr}");
}
- Step 3: Build the Data + Worker projects to verify compilation.
Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
Expected: Build succeeded, 0 errors.
- Step 4: Commit
git add src/ClaudeDo.Data/Git/GitService.cs
git commit -m "feat(git): add conflict-stage blob reads and single-path staging"
Task 2: TaskMergeService conflict reads + resolution writes
Files:
-
Modify:
src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs -
Test:
tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs -
Step 1: Write failing tests (append inside
TaskMergeServiceTests, before#region Test doubles). Reuse the existing helpersSeedListAndTask,SeedWorktree,BuildService, and theGitRepoFixtureconflict setup pattern fromContinueMergeAsync_AfterUserResolves....
[Fact]
public async Task GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPath));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c1", wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
await SeedWorktree(db, task.Id, wtPath, "claudedo/c1", repo.BaseCommit);
var (svc, _) = BuildService(db);
var start = await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, start.Status);
var conflicts = await svc.GetConflictsAsync(task.Id, CancellationToken.None);
Assert.Equal(task.Id, conflicts.TaskId);
var file = Assert.Single(conflicts.Files);
Assert.Equal("README.md", file.Path);
Assert.Contains("main change", file.Ours); // ours = target (main) side after checkout
Assert.Contains("branch change", file.Theirs); // theirs = merged-in branch
Assert.NotNull(file.Base);
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
}
[Fact]
public async Task WriteResolutionAsync_ThenContinue_CompletesMerge()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPath));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c2", wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
await SeedWorktree(db, task.Id, wtPath, "claudedo/c2", repo.BaseCommit);
var (svc, _) = BuildService(db);
await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
await svc.WriteResolutionAsync(task.Id, "README.md", "# resolved by user\n", CancellationToken.None);
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
Assert.Equal("# resolved by user\n", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
}
- Step 2: Run tests to verify they fail (no such methods).
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs|WriteResolutionAsync_ThenContinue_CompletesMerge"
Expected: compile error / FAIL (methods don't exist).
- Step 3: Add records + methods to
TaskMergeService.cs.
Add records beside MergeResult (top of file, after the existing record declarations):
public sealed record MergeConflicts(
string TaskId,
IReadOnlyList<ConflictFileContent> Files);
public sealed record ConflictFileContent(
string Path,
string Ours,
string Theirs,
string? Base);
Add methods inside the class (after AbortMergeAsync):
public async Task<MergeConflicts> GetConflictsAsync(string taskId, CancellationToken ct)
{
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
if (string.IsNullOrWhiteSpace(list.WorkingDir))
throw new InvalidOperationException("list has no working directory");
var files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
var result = new List<ConflictFileContent>(files.Count);
foreach (var path in files)
{
var ours = await _git.ShowStageAsync(list.WorkingDir, 2, path, ct) ?? "";
var theirs = await _git.ShowStageAsync(list.WorkingDir, 3, path, ct) ?? "";
var @base = await _git.ShowStageAsync(list.WorkingDir, 1, path, ct);
result.Add(new ConflictFileContent(path, ours, theirs, @base));
}
return new MergeConflicts(taskId, result);
}
public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
{
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
if (string.IsNullOrWhiteSpace(list.WorkingDir))
throw new InvalidOperationException("list has no working directory");
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
await File.WriteAllTextAsync(full, content, ct);
await _git.AddPathAsync(list.WorkingDir, path, ct);
}
(Note: Path is System.IO.Path — the file already uses it via other helpers; the record property Path does not shadow it inside these methods because it's accessed as a static type, not an instance member.)
- Step 4: Run the tests to verify they pass.
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs|WriteResolutionAsync_ThenContinue_CompletesMerge"
Expected: PASS (2 tests). If git unavailable they no-op.
- Step 5: Commit
git add src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
git commit -m "feat(merge): read conflict stages and write user resolutions"
Task 3: WorkerHub conflict methods + DTOs
Files:
-
Modify:
src/ClaudeDo.Worker/Hub/WorkerHub.cs -
Step 1: Add DTOs beside the existing merge DTOs (after
public record MergeTargetsDto(...)):
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
- Step 2: Add the 5 hub methods (after
PreviewMerge). Names/params/returns MUST match the frozen client calls.
public Task<MergeResultDto> StartConflictMerge(string taskId, string targetBranch)
=> HubGuard(async () =>
{
var r = await _mergeService.MergeAsync(
taskId, targetBranch ?? "", removeWorktree: false, "Merge task",
leaveConflictsInTree: true, CancellationToken.None);
if (r.Status == TaskMergeService.StatusBlocked)
throw new HubException(r.ErrorMessage ?? "merge blocked");
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
});
public Task<MergeConflictsDto> GetMergeConflicts(string taskId)
=> HubGuard(async () =>
{
var c = await _mergeService.GetConflictsAsync(taskId, CancellationToken.None);
return new MergeConflictsDto(
c.TaskId,
c.Files.Select(f => new ConflictFileDto(
f.Path,
new[] { new ConflictHunkDto(f.Ours, f.Theirs, f.Base) })).ToList());
});
public Task WriteConflictResolution(string taskId, string path, string resolvedContent)
=> HubGuard(() => _mergeService.WriteResolutionAsync(
taskId, path, resolvedContent ?? "", CancellationToken.None));
public Task<MergeResultDto> ContinueMerge(string taskId)
=> HubGuard(async () =>
{
var r = await _mergeService.ContinueMergeAsync(taskId, CancellationToken.None);
if (r.Status == TaskMergeService.StatusBlocked)
throw new HubException(r.ErrorMessage ?? "continue failed");
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
});
public Task AbortMerge(string taskId)
=> HubGuard(async () =>
{
var r = await _mergeService.AbortMergeAsync(taskId, CancellationToken.None);
if (r.Status == TaskMergeService.StatusBlocked)
throw new HubException(r.ErrorMessage ?? "abort failed");
});
- Step 3: Build the Worker project to verify compilation.
Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
Expected: Build succeeded, 0 errors.
- Step 4: Commit
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs
git commit -m "feat(hub): expose conflict-resolution merge methods"
Task 4: Conflict UI model
Files:
-
Create:
src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs -
Test:
tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs(model tests added here in Task 5; this task is build-verified) -
Step 1: Create the model file. Shaped so a 3-way pane needs no model change (
Baseretained per hunk).
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Conflicts;
public sealed partial class ConflictHunk : ObservableObject
{
public string Ours { get; }
public string Theirs { get; }
public string? Base { get; }
[ObservableProperty] private string? _resolution;
public bool IsResolved => Resolution is not null;
public ConflictHunk(string ours, string theirs, string? @base)
{
Ours = ours;
Theirs = theirs;
Base = @base;
}
partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved));
[RelayCommand] private void AcceptCurrent() => Resolution = Ours;
[RelayCommand] private void AcceptIncoming() => Resolution = Theirs;
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
[RelayCommand] private void EditManually() => Resolution ??= Ours;
}
public sealed class ConflictFile
{
public string Path { get; }
public IReadOnlyList<ConflictHunk> Hunks { get; }
public ConflictFile(string path, IReadOnlyList<ConflictHunk> hunks)
{
Path = path;
Hunks = hunks;
}
public bool AllHunksResolved => Hunks.Count > 0 && Hunks.All(h => h.IsResolved);
/// <summary>The merged file content: concatenation of each hunk's resolution
/// (single whole-file hunk today; concatenation keeps it correct for multi-hunk later).</summary>
public string ComposeResolvedContent() => string.Concat(Hunks.Select(h => h.Resolution));
}
- Step 2: Build the Ui project to verify compilation.
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
Expected: Build succeeded.
- Step 3: Commit
git add src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs
git commit -m "feat(ui): add inline conflict model (file/hunk with resolution)"
Task 5: ConflictResolverViewModel
Files:
-
Create:
src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs -
Test:
tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs -
Step 1: Write failing tests. Subclass the existing
StubWorkerClient(its conflict methods arevirtual).
using System.Collections.Generic;
using System.Threading.Tasks;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Conflicts;
using Xunit;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class ConflictResolverViewModelTests
{
private sealed class FakeWorker : StubWorkerClient
{
public string? WrittenPath;
public string? WrittenContent;
public bool Continued;
public bool Aborted;
public string ContinueStatus = "merged";
public override Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
=> Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null));
public override Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
=> Task.FromResult(new MergeConflictsDto(taskId, new[]
{
new ConflictFileDto("README.md", new[] { new ConflictHunkDto("ours\n", "theirs\n", "base\n") })
}));
public override Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
{
WrittenPath = path; WrittenContent = resolvedContent; return Task.CompletedTask;
}
public override Task<MergeResultDto> ContinueMergeAsync(string taskId)
{
Continued = true;
return Task.FromResult(new MergeResultDto(ContinueStatus, System.Array.Empty<string>(), null));
}
public override Task AbortMergeAsync(string taskId) { Aborted = true; return Task.CompletedTask; }
}
[Fact]
public async Task OpenAsync_LoadsConflicts_AndBlocksContinueUntilResolved()
{
var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1");
var hasConflicts = await vm.OpenAsync("main");
Assert.True(hasConflicts);
var file = Assert.Single(vm.Files);
Assert.Equal("README.md", file.Path);
Assert.False(vm.CanContinue); // nothing resolved yet
file.Hunks[0].AcceptIncomingCommand.Execute(null);
Assert.True(vm.CanContinue); // every hunk resolved
}
[Fact]
public async Task Continue_WritesComposedResolution_AndClosesOnMerged()
{
var worker = new FakeWorker();
var vm = new ConflictResolverViewModel(worker, "task-1");
var closed = false;
vm.CloseRequested = () => closed = true;
await vm.OpenAsync("main");
vm.Files[0].Hunks[0].AcceptCurrentCommand.Execute(null); // resolution = "ours\n"
await vm.ContinueCommand.ExecuteAsync(null);
Assert.Equal("README.md", worker.WrittenPath);
Assert.Equal("ours\n", worker.WrittenContent);
Assert.True(worker.Continued);
Assert.True(closed);
}
[Fact]
public async Task Continue_StaysOpenAndReportsError_WhenStillConflicted()
{
var worker = new FakeWorker { ContinueStatus = "conflict" };
var vm = new ConflictResolverViewModel(worker, "task-1");
var closed = false;
vm.CloseRequested = () => closed = true;
await vm.OpenAsync("main");
vm.Files[0].Hunks[0].AcceptBothCommand.Execute(null);
await vm.ContinueCommand.ExecuteAsync(null);
Assert.False(closed);
Assert.NotNull(vm.Error);
}
[Fact]
public async Task Abort_CallsWorkerAndCloses()
{
var worker = new FakeWorker();
var vm = new ConflictResolverViewModel(worker, "task-1");
var closed = false;
vm.CloseRequested = () => closed = true;
await vm.AbortCommand.ExecuteAsync(null);
Assert.True(worker.Aborted);
Assert.True(closed);
}
}
- Step 2: Run tests to verify they fail (VM not defined).
Run: dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "ConflictResolverViewModelTests"
Expected: compile error / FAIL.
- Step 3: Implement the ViewModel.
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.ViewModels.Conflicts;
public sealed partial class ConflictResolverViewModel : ObservableObject
{
private readonly IWorkerClient _worker;
private readonly string _taskId;
public ObservableCollection<ConflictFile> Files { get; } = new();
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string? _error;
[ObservableProperty] private bool _canContinue;
public string TaskId => _taskId;
public Action? CloseRequested { get; set; }
public ConflictResolverViewModel(IWorkerClient worker, string taskId)
{
_worker = worker;
_taskId = taskId;
}
/// <summary>Starts the conflict merge and loads ours/theirs/base per file.
/// Returns true when there are conflicts to resolve (caller should show the dialog).</summary>
public async Task<bool> OpenAsync(string targetBranch)
{
IsBusy = true;
Error = null;
try
{
var start = await _worker.StartConflictMergeAsync(_taskId, targetBranch);
if (!string.Equals(start.Status, "conflict", StringComparison.Ordinal))
{
if (string.Equals(start.Status, "blocked", StringComparison.Ordinal))
Error = start.ErrorMessage;
return false;
}
var conflicts = await _worker.GetMergeConflictsAsync(_taskId);
Files.Clear();
foreach (var f in conflicts.Files)
{
var hunks = f.Hunks.Select(h =>
{
var hk = new ConflictHunk(h.Ours, h.Theirs, h.Base);
hk.PropertyChanged += OnHunkChanged;
return hk;
}).ToList();
Files.Add(new ConflictFile(f.Path, hunks));
}
RecomputeCanContinue();
return Files.Count > 0;
}
catch (Exception ex)
{
Error = ex.Message;
return false;
}
finally { IsBusy = false; }
}
private void OnHunkChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(ConflictHunk.IsResolved) or nameof(ConflictHunk.Resolution))
RecomputeCanContinue();
}
private void RecomputeCanContinue()
=> CanContinue = Files.Count > 0 && Files.All(f => f.AllHunksResolved);
[RelayCommand]
private async Task ContinueAsync()
{
if (!CanContinue) return;
IsBusy = true;
Error = null;
try
{
foreach (var file in Files)
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.ComposeResolvedContent());
var result = await _worker.ContinueMergeAsync(_taskId);
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
CloseRequested?.Invoke();
else
Error = result.ErrorMessage ?? "Conflicts not fully resolved — review and retry.";
}
catch (Exception ex)
{
Error = ex.Message;
}
finally { IsBusy = false; }
}
[RelayCommand]
private async Task AbortAsync()
{
IsBusy = true;
try { await _worker.AbortMergeAsync(_taskId); }
catch (Exception ex) { Error = ex.Message; }
finally
{
IsBusy = false;
CloseRequested?.Invoke();
}
}
}
- Step 4: Run tests to verify they pass.
Run: dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "ConflictResolverViewModelTests"
Expected: PASS (4 tests).
- Step 5: Commit
git add src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs
git commit -m "feat(ui): add inline conflict resolver view-model"
Task 6: ConflictResolverView + localization
Files:
-
Create:
src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml -
Create:
src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs -
Modify:
src/ClaudeDo.Localization/locales/en.json -
Modify:
src/ClaudeDo.Localization/locales/de.json -
Step 1: Add localization keys to
en.jsonas a new top-level section (sibling of"planning"):
"conflictResolver": {
"windowTitle": "Resolve merge conflicts",
"modalTitle": "RESOLVE CONFLICTS",
"loading": "Loading conflicts…",
"current": "Current (ours)",
"incoming": "Incoming (theirs)",
"mergedResult": "Merged result",
"acceptCurrent": "Accept Current",
"acceptIncoming": "Accept Incoming",
"acceptBoth": "Accept Both",
"editManually": "Edit manually",
"continue": "Resolve & continue",
"abort": "Abort merge"
},
- Step 2: Add the SAME keys to
de.json(German values, identical key set — parity enforced by Localization.Tests):
"conflictResolver": {
"windowTitle": "Merge-Konflikte lösen",
"modalTitle": "KONFLIKTE LÖSEN",
"loading": "Konflikte werden geladen…",
"current": "Aktuell (unsere)",
"incoming": "Eingehend (ihre)",
"mergedResult": "Zusammengeführtes Ergebnis",
"acceptCurrent": "Aktuelle übernehmen",
"acceptIncoming": "Eingehende übernehmen",
"acceptBoth": "Beide übernehmen",
"editManually": "Manuell bearbeiten",
"continue": "Lösen & fortfahren",
"abort": "Merge abbrechen"
},
- Step 3: Create the View (
ConflictResolverView.axaml). AWindowusingModalShell, mirroringConflictResolutionView.axaml. Two stacked read-only boxes (ours/theirs), a button row, and a two-way merged-result box per hunk.
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:DataType="vm:ConflictResolverViewModel"
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
Title="{loc:Tr conflictResolver.windowTitle}"
Width="760" Height="640" MinWidth="560" MinHeight="420"
CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
</Window.KeyBindings>
<ctl:ModalShell Title="{loc:Tr conflictResolver.modalTitle}" CloseCommand="{Binding AbortCommand}">
<ctl:ModalShell.Footer>
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" VerticalAlignment="Center">
<Button Classes="btn" Content="{loc:Tr conflictResolver.continue}"
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
</StackPanel>
</ctl:ModalShell.Footer>
<Grid RowDefinitions="Auto,*" Margin="16,12">
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,8"
Text="{loc:Tr conflictResolver.loading}"
IsVisible="{Binding IsBusy}"/>
<TextBlock Grid.Row="0" Classes="meta" Foreground="{DynamicResource BloodBrush}"
Text="{Binding Error}" TextWrapping="Wrap"
IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding Files}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictFile">
<StackPanel Spacing="8" Margin="0,0,0,16">
<TextBlock Classes="path-mono heading" Text="{Binding Path}"/>
<ItemsControl ItemsSource="{Binding Hunks}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictHunk">
<Border BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
CornerRadius="6" Padding="10" Margin="0,0,0,8">
<StackPanel Spacing="6">
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.current}"/>
<TextBox Text="{Binding Ours, Mode=OneWay}" IsReadOnly="True"
TextWrapping="NoWrap" AcceptsReturn="True" MaxHeight="120"
FontFamily="{DynamicResource MonoFont}"/>
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.incoming}"/>
<TextBox Text="{Binding Theirs, Mode=OneWay}" IsReadOnly="True"
TextWrapping="NoWrap" AcceptsReturn="True" MaxHeight="120"
FontFamily="{DynamicResource MonoFont}"/>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptCurrent}"
Command="{Binding AcceptCurrentCommand}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptIncoming}"
Command="{Binding AcceptIncomingCommand}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptBoth}"
Command="{Binding AcceptBothCommand}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.editManually}"
Command="{Binding EditManuallyCommand}"/>
</StackPanel>
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.mergedResult}"/>
<TextBox Text="{Binding Resolution, Mode=TwoWay}"
TextWrapping="NoWrap" AcceptsReturn="True" MinHeight="80" MaxHeight="200"
FontFamily="{DynamicResource MonoFont}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</ctl:ModalShell>
</Window>
Note for the implementer: if MonoFont / path-mono / heading / meta / btn resource keys or style classes don't resolve at build, drop the FontFamily attribute and unknown Classes (keep btn) — match whatever the existing ConflictResolutionView.axaml and app styles actually expose. Verify against src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml and the app's style resources before finalizing.
- Step 4: Create the code-behind (
ConflictResolverView.axaml.cs):
using Avalonia.Controls;
using ClaudeDo.Ui.ViewModels.Conflicts;
namespace ClaudeDo.Ui.Views.Conflicts;
public partial class ConflictResolverView : Window
{
public ConflictResolverView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(System.EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is ConflictResolverViewModel vm)
vm.CloseRequested = Close;
}
}
- Step 5: Build the App + run Localization tests.
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release && dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
Expected: Build succeeded; localization parity tests PASS.
- Step 6: Commit
git add src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json
git commit -m "feat(ui): add inline conflict resolver view and localization"
Task 7: Wire factory + dialog seam for the integrator
Files:
- Modify:
src/ClaudeDo.App/Program.cs - Modify:
src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs - Modify:
src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
These are additive seams only. The integrator connects Layer A/B's RequestConflictResolution(taskId, target) callback to IslandsShellViewModel.RequestConflictResolutionAsync.
- Step 1: Register the factory in
Program.cs(in the ViewModels region, near the otherFunc<>factories). Only theFunc<>factory is needed — the VM is never resolved directly:
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
sp.GetRequiredService<WorkerClient>(), taskId));
Then, after IslandsShellViewModel is registered, set the factory on it once resolved. Replace the existing sc.AddSingleton<IslandsShellViewModel>(); registration with a factory that injects the conflict-resolver factory:
sc.AddSingleton<IslandsShellViewModel>(sp =>
{
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
shell.ConflictResolverFactory =
sp.GetRequiredService<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>();
return shell;
});
(ActivatorUtilities.CreateInstance resolves the existing big constructor + its Func<> deps exactly as the default registration did.)
- Step 2: Add the additive seam to
IslandsShellViewModel(near the otherShow*delegate properties):
// Layer C seam: composition root sets the factory; MainWindow sets the dialog opener.
// The integrator connects Layer A/B's RequestConflictResolution(taskId, target) to this method.
public Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>? ConflictResolverFactory { get; set; }
public Func<ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel, Task>? ShowConflictResolver { get; set; }
public async Task RequestConflictResolutionAsync(string taskId, string targetBranch)
{
if (ConflictResolverFactory is null || ShowConflictResolver is null) return;
var vm = ConflictResolverFactory(taskId);
var hasConflicts = await vm.OpenAsync(targetBranch);
if (hasConflicts)
await ShowConflictResolver(vm);
}
(Add using ClaudeDo.Ui.ViewModels.Conflicts; or use fully-qualified names as above.)
- Step 3: Wire the dialog opener in
MainWindow.axaml.csinsideOnDataContextChanged, alongside the othervm.Show*assignments:
vm.ShowConflictResolver = async (resolverVm) =>
{
var dlg = new ClaudeDo.Ui.Views.Conflicts.ConflictResolverView { DataContext = resolverVm };
await dlg.ShowDialog(this);
};
- Step 4: Build the App to verify compilation.
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
Expected: Build succeeded.
- Step 5: Commit
git add src/ClaudeDo.App/Program.cs src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
git commit -m "feat(ui): expose conflict-resolver factory and dialog seam for integrator"
Task 8: Full verification
- Step 1: Build both head projects.
Run:
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
Expected: both Build succeeded, 0 errors/warnings.
- Step 2: Run the full relevant test suites.
Run:
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
Expected: all PASS.
-
Step 3: Flag visual verification. The resolver dialog cannot be opened end-to-end until the integrator wires Layer A/B's
RequestConflictResolution(taskId, target)→IslandsShellViewModel.RequestConflictResolutionAsync. Report this as a visual-verification gap for the user/integrator: open a real conflicting merge, confirm hunks render, Accept buttons populate the merged box, Resolve & continue closes on success, Abort restores the tree. -
Step 4: Leave the branch for the orchestrator. Do NOT push, do NOT merge to main.