# 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<ConflictFileDto> Files)`, `ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks)`, `ConflictHunkDto(string Ours, string Theirs, string? Base)`, plus existing `MergeResultDto(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`):**
```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<string, ConflictResolverViewModel>`.
- 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
/// <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**

```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<ConflictFileContent> Files);

public sealed record ConflictFileContent(
    string Path,
    string Ours,
    string Theirs,
    string? Base);
```

Add methods inside the class (after `AbortMergeAsync`):
```csharp
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**

```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<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.

```csharp
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**

```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<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**

```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<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.**

```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<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**

```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
<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`):

```csharp
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**

```bash
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 other `Func<>` factories). Only the `Func<>` factory is needed — the VM is never resolved directly:

```csharp
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:

```csharp
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 other `Show*` delegate properties):

```csharp
// 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.cs`** inside `OnDataContextChanged`, alongside the other `vm.Show*` assignments:

```csharp
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**

```bash
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:
```bash
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:
```bash
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.
