# 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.cs` already call hub methods by name: `StartConflictMerge`, `GetMergeConflicts`, `WriteConflictResolution`, `ContinueMerge`, `AbortMerge`. - Client DTOs already exist in `WorkerClient.cs`: `MergeConflictsDto(string TaskId, IReadOnlyList Files)`, `ConflictFileDto(string Path, IReadOnlyList Hunks)`, `ConflictHunkDto(string Ours, string Theirs, string? Base)`, plus existing `MergeResultDto(string Status, IReadOnlyList 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`):** ```bash 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` — add `ShowStageAsync` (untrimmed blob read) + `AddPathAsync`; add `trimOutput` param to `RunGitAsync`. - Modify `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` — add records `MergeConflicts`/`ConflictFileContent`; add `GetConflictsAsync` + `WriteResolutionAsync`. - Modify `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — add DTOs `MergeConflictsDto`/`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` — register `ConflictResolverViewModel` + `Func`. - Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — additive seam (`ConflictResolverFactory`, `ShowConflictResolver`, `RequestConflictResolutionAsync`). - Modify `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs` — wire `ShowConflictResolver` dialog 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 `trimOutput` param to `RunGitAsync`** so blob reads keep exact bytes. In `RunGitAsync` signature add `bool trimOutput = true`, and change the return to: ```csharp return (proc.ExitCode, trimOutput ? stdout.TrimEnd() : stdout, stderr.TrimEnd()); ``` (All existing callers keep the default `true`.) - [ ] **Step 2: Add `ShowStageAsync` + `AddPathAsync`** (place after `ListConflictedFilesAsync`): ```csharp /// /// 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. /// public async Task 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** ```bash 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 helpers `SeedListAndTask`, `SeedWorktree`, `BuildService`, and the `GitRepoFixture` conflict setup pattern from `ContinueMergeAsync_AfterUserResolves...`. ```csharp [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): ```csharp public sealed record MergeConflicts( string TaskId, IReadOnlyList Files); public sealed record ConflictFileContent( string Path, string Ours, string Theirs, string? Base); ``` Add methods inside the class (after `AbortMergeAsync`): ```csharp public async Task 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(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** ```bash 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(...)`): ```csharp public record MergeConflictsDto(string TaskId, IReadOnlyList Files); public record ConflictFileDto(string Path, IReadOnlyList 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. ```csharp public Task 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 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 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** ```bash 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 (`Base` retained per hunk). ```csharp 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 Hunks { get; } public ConflictFile(string path, IReadOnlyList hunks) { Path = path; Hunks = hunks; } public bool AllHunksResolved => Hunks.Count > 0 && Hunks.All(h => h.IsResolved); /// The merged file content: concatenation of each hunk's resolution /// (single whole-file hunk today; concatenation keeps it correct for multi-hunk later). 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** ```bash 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 are `virtual`). ```csharp 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 StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null)); public override Task 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 ContinueMergeAsync(string taskId) { Continued = true; return Task.FromResult(new MergeResultDto(ContinueStatus, System.Array.Empty(), 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.** ```csharp 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 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; } /// Starts the conflict merge and loads ours/theirs/base per file. /// Returns true when there are conflicts to resolve (caller should show the dialog). public async Task 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** ```bash 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.json` as a new top-level section (sibling of `"planning"`): ```json "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): ```json "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`). A `Window` using `ModalShell`, mirroring `ConflictResolutionView.axaml`. Two stacked read-only boxes (ours/theirs), a button row, and a two-way merged-result box per hunk. ```xml