# Layer B — Multi-Worktree Merge Cockpit Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Turn the worktrees-overview modal into a batch-merge cockpit (multi-select N worktrees → one target branch → "Merge all" with skip-and-continue conflict collection), and migrate `WorktreeModalView`'s bespoke inline diff onto the canonical `DiffLinesView`. **Architecture:** The cockpit VM keeps depending on the concrete `WorkerClient` (the overview/cleanup/state methods live only on `WorkerClient`, not `IWorkerClient`). The batch loop is extracted into a delegate-driven method `MergeSelectedAsync(Func<...> mergeFn)` so it is unit-testable with a fake merge function and a never-connected `WorkerClient`. Clean merges (`Status=="merged"`) update the row; conflicts (`Status=="conflict"`, which `MergeTaskAsync` already auto-aborts) are collected into a `ConflictRows` list whose rows expose a `Resolve` button wired to an inert `RequestConflictResolution(taskId, targetBranch)` seam. The diff migration replaces the right-pane `ItemsControl` in `WorktreeModalView` with `DiffLinesView`, feeding it `DiffLineViewModel`s produced by `UnifiedDiffParser`, and deletes the now-dead `WorktreeDiffLineViewModel`/`WorktreeDiffLineKind`. **Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm source generators, xUnit. Build UI with `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`; run `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`. **Frozen contracts reused (do NOT modify):** - `WorkerClient.MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) -> Task` - `WorkerClient.GetMergeTargetsAsync(string taskId) -> Task` (`MergeTargetsDto(string DefaultBranch, IReadOnlyList LocalBranches)`) - `MergeResultDto(string Status, IReadOnlyList ConflictFiles, string? ErrorMessage)` — `Status` is `"merged" | "conflict" | "blocked" | ` - `WorkerClient.GetWorktreesOverviewAsync`, `CleanupFinishedWorktreesAsync`, `SetWorktreeStateAsync`, `ForceRemoveWorktreeAsync` - `GitService.GetFileDiffAsync(worktreePath, baseCommit?, relativePath)` returns a `git diff` blob including the `diff --git` header (so `UnifiedDiffParser.Parse` handles it) - `DiffLinesView` (`Lines` styled property, `IEnumerable?`), `DiffLineViewModel`, `DiffFileViewModel`, `UnifiedDiffParser.Parse` / `.Flatten` **Do NOT touch:** any worker-side files (`WorkerHub`, `TaskMergeService`, `GitService`), `IWorkerClient.cs` / `WorkerClient.cs`, `WorkConsole.axaml`, `DetailsIslandViewModel.cs`, and do not create any `ConflictResolver` UI or reference any `ConflictResolver` type. --- ## File Structure - `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs` — **modify.** Add `BatchMergeOutcome` enum; add `IsChecked`/`MergeOutcome` (+ derived) to the row VM; add `MergeTargets`, `SelectedTarget`, `SelectedCount`, `IsMerging`, `BatchProgress`, `ConflictRows`, the `RequestConflictResolution` seam, `MergeSelectedAsync`, `MergeAllCommand`, `ResolveConflictCommand`, `ToggleSelectAllCommand`, target loading, and per-row check subscription. Keep all existing context-menu commands/wiring intact. - `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml` — **modify.** Add a per-row checkbox + outcome badge, a target `ComboBox` + "Merge all" button + progress text in the toolbar, and a "Needs resolution" panel listing `ConflictRows` with `Resolve` buttons. - `src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs` — **modify.** Replace `SelectedFileDiffLines` element type with `DiffLineViewModel` produced via `UnifiedDiffParser`; delete `WorktreeDiffLineKind` and `WorktreeDiffLineViewModel`. - `src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml` — **modify.** Replace the right-pane `ItemsControl` with `ctl:DiffLinesView`; drop the `DiffLineKindToBrushConverter` resource. - `src/ClaudeDo.Localization/locales/en.json` + `de.json` — **modify.** Add new `modals.worktreesOverview.*` and `vm.worktreesOverview.*` keys (keep parity). - `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs` — **create.** Unit tests for `MergeSelectedAsync` skip-and-continue, conflict collection, progress, selection gating, and the resolve seam. No `IWorkerClient` change → no test-fake updates needed. --- ## Task 1: Row-level batch state (outcome enum + row VM fields) **Files:** - Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs` - Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs` - [ ] **Step 1: Write the failing test** Create `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`: ```csharp using ClaudeDo.Data.Models; using ClaudeDo.Ui.ViewModels.Modals; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using Xunit; namespace ClaudeDo.Ui.Tests.ViewModels; public class WorktreesOverviewBatchMergeTests { private static WorktreeOverviewRowViewModel ActiveRow(string id) => new() { TaskId = id, TaskTitle = $"Task {id}", TaskStatus = TaskStatus.WaitingForReview, State = WorktreeState.Active, }; [Fact] public void Row_outcome_helpers_reflect_state() { var row = ActiveRow("a"); Assert.Equal(BatchMergeOutcome.None, row.MergeOutcome); Assert.False(row.IsConflict); row.MergeOutcome = BatchMergeOutcome.Conflict; Assert.True(row.IsConflict); row.MergeOutcome = BatchMergeOutcome.Merged; Assert.False(row.IsConflict); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests` Expected: FAIL — `BatchMergeOutcome` and `MergeOutcome`/`IsConflict` do not exist (compile error). - [ ] **Step 3: Add the enum and row fields** In `WorktreesOverviewModalViewModel.cs`, add the enum just above `WorktreeOverviewRowViewModel`: ```csharp public enum BatchMergeOutcome { None, Merging, Merged, Conflict, Blocked, Failed } ``` Inside `WorktreeOverviewRowViewModel`, add after the existing `_isSelected` field: ```csharp [ObservableProperty] private bool _isChecked; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsConflict))] [NotifyPropertyChangedFor(nameof(HasOutcome))] private BatchMergeOutcome _mergeOutcome; public bool IsConflict => MergeOutcome == BatchMergeOutcome.Conflict; public bool HasOutcome => MergeOutcome != BatchMergeOutcome.None; ``` - [ ] **Step 4: Run test to verify it passes** Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests` Expected: PASS (1 test). - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs git commit -m "feat(ui): add batch-merge row state to worktrees cockpit VM" ``` --- ## Task 2: Batch orchestration (`MergeSelectedAsync` skip-and-continue) **Files:** - Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs` - Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs` - [ ] **Step 1: Write the failing tests** Append to `WorktreesOverviewBatchMergeTests.cs`. The helper builds a VM with a never-connected `WorkerClient` (the loop never touches it) and seeds `Rows` directly: ```csharp private static WorktreesOverviewModalViewModel NewVm() => new(new ClaudeDo.Ui.Services.WorkerClient("http://127.0.0.1:1/hub"), () => null!); private static MergeResultDto Merged() => new("merged", System.Array.Empty(), null); private static MergeResultDto Conflict() => new("conflict", new[] { "f.cs" }, null); private static MergeResultDto Blocked() => new("blocked", System.Array.Empty(), "blocked"); [Fact] public async System.Threading.Tasks.Task MergeSelected_only_processes_checked_active_rows() { var vm = NewVm(); var a = ActiveRow("a"); a.IsChecked = true; var b = ActiveRow("b"); b.IsChecked = false; // unchecked -> skipped var c = ActiveRow("c"); c.IsChecked = true; c.State = WorktreeState.Merged; // not active -> skipped vm.Rows.Add(a); vm.Rows.Add(b); vm.Rows.Add(c); vm.SelectedTarget = "main"; var seen = new System.Collections.Generic.List(); await vm.MergeSelectedAsync((id, target, remove, msg) => { seen.Add(id); Assert.Equal("main", target); Assert.False(remove); // removeWorktree must be false return System.Threading.Tasks.Task.FromResult(Merged()); }); Assert.Equal(new[] { "a" }, seen); Assert.Equal(BatchMergeOutcome.Merged, a.MergeOutcome); Assert.False(a.IsChecked); // cleared after merge } [Fact] public async System.Threading.Tasks.Task MergeSelected_continues_past_conflict_and_collects_it() { var vm = NewVm(); var a = ActiveRow("a"); a.IsChecked = true; var b = ActiveRow("b"); b.IsChecked = true; var c = ActiveRow("c"); c.IsChecked = true; vm.Rows.Add(a); vm.Rows.Add(b); vm.Rows.Add(c); vm.SelectedTarget = "main"; await vm.MergeSelectedAsync((id, target, remove, msg) => System.Threading.Tasks.Task.FromResult(id == "b" ? Conflict() : Merged())); Assert.Equal(BatchMergeOutcome.Merged, a.MergeOutcome); Assert.Equal(BatchMergeOutcome.Conflict, b.MergeOutcome); Assert.Equal(BatchMergeOutcome.Merged, c.MergeOutcome); // continued past the conflict Assert.Contains(b, vm.ConflictRows); Assert.Single(vm.ConflictRows); } [Fact] public async System.Threading.Tasks.Task MergeSelected_maps_blocked_and_exception_to_failure_outcomes() { var vm = NewVm(); var a = ActiveRow("a"); a.IsChecked = true; var b = ActiveRow("b"); b.IsChecked = true; vm.Rows.Add(a); vm.Rows.Add(b); vm.SelectedTarget = "main"; await vm.MergeSelectedAsync((id, target, remove, msg) => id == "a" ? System.Threading.Tasks.Task.FromResult(Blocked()) : throw new System.InvalidOperationException("boom")); Assert.Equal(BatchMergeOutcome.Blocked, a.MergeOutcome); Assert.Equal(BatchMergeOutcome.Failed, b.MergeOutcome); Assert.Empty(vm.ConflictRows); Assert.False(vm.IsMerging); } [Fact] public async System.Threading.Tasks.Task MergeSelected_noop_when_no_target() { var vm = NewVm(); var a = ActiveRow("a"); a.IsChecked = true; vm.Rows.Add(a); vm.SelectedTarget = null; var called = false; await vm.MergeSelectedAsync((id, t, r, m) => { called = true; return System.Threading.Tasks.Task.FromResult(Merged()); }); Assert.False(called); Assert.Equal(BatchMergeOutcome.None, a.MergeOutcome); } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests` Expected: FAIL — `MergeSelectedAsync`, `ConflictRows`, `IsMerging`, `SelectedTarget` do not exist (compile error). - [ ] **Step 3: Implement the orchestration + cockpit fields** In `WorktreesOverviewModalViewModel.cs`, add these `using`s if missing: `using ClaudeDo.Ui.Services;` (already present). Add fields/properties to `WorktreesOverviewModalViewModel` (after the existing `_selectedRow` field): ```csharp [ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private string? _selectedTarget; [ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private int _selectedCount; [ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private bool _isMerging; [ObservableProperty] private string? _batchProgress; public ObservableCollection MergeTargets { get; } = new(); public ObservableCollection ConflictRows { get; } = new(); /// Inert seam wired by the integrator to Layer C's resolver at merge time. (taskId, targetBranch) public Func? RequestConflictResolution { get; set; } ``` Add a helper to enumerate rows regardless of grouped/flat mode, plus the orchestration method: ```csharp public IEnumerable AllRows => IsGlobal ? Groups.SelectMany(g => g.Rows) : Rows; public async Task MergeSelectedAsync( Func> mergeFn, CancellationToken ct = default) { var target = SelectedTarget; if (string.IsNullOrWhiteSpace(target)) return; var selected = AllRows.Where(r => r.IsChecked && r.IsActive).ToList(); if (selected.Count == 0) return; IsMerging = true; ConflictRows.Clear(); var done = 0; try { foreach (var row in selected) { ct.ThrowIfCancellationRequested(); row.MergeOutcome = BatchMergeOutcome.Merging; BatchProgress = Loc.T("vm.worktreesOverview.batchProgress", ++done, selected.Count); MergeResultDto result; try { result = await mergeFn(row.TaskId, target!, false, Loc.T("vm.merge.commitMessage", row.TaskTitle)); } catch { row.MergeOutcome = BatchMergeOutcome.Failed; continue; } switch (result.Status) { case "merged": row.MergeOutcome = BatchMergeOutcome.Merged; row.State = WorktreeState.Merged; row.IsChecked = false; break; case "conflict": row.MergeOutcome = BatchMergeOutcome.Conflict; ConflictRows.Add(row); break; case "blocked": row.MergeOutcome = BatchMergeOutcome.Blocked; break; default: row.MergeOutcome = BatchMergeOutcome.Failed; break; } } BatchProgress = Loc.T("vm.worktreesOverview.batchDone", selected.Count(r => r.MergeOutcome == BatchMergeOutcome.Merged), ConflictRows.Count); } finally { IsMerging = false; } } ``` > Note: `Loc.T` keys are added in Task 5; they resolve to the key name (harmless) until then, so tests pass now. - [ ] **Step 4: Run tests to verify they pass** Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests` Expected: PASS (5 tests). - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs git commit -m "feat(ui): add skip-and-continue batch merge orchestration" ``` --- ## Task 3: Selection tracking, target loading, commands + resolve seam **Files:** - Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs` - Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs` - [ ] **Step 1: Write the failing tests** Append to `WorktreesOverviewBatchMergeTests.cs`: ```csharp [Fact] public void SelectedCount_tracks_checked_active_rows() { var vm = NewVm(); var a = ActiveRow("a"); var b = ActiveRow("b"); var merged = ActiveRow("c"); merged.State = WorktreeState.Merged; vm.AddRowForTest(a); vm.AddRowForTest(b); vm.AddRowForTest(merged); Assert.Equal(0, vm.SelectedCount); a.IsChecked = true; Assert.Equal(1, vm.SelectedCount); b.IsChecked = true; merged.IsChecked = true; // not active -> not counted Assert.Equal(2, vm.SelectedCount); a.IsChecked = false; Assert.Equal(1, vm.SelectedCount); } [Fact] public void ResolveConflict_invokes_seam_with_task_and_target() { var vm = NewVm(); vm.SelectedTarget = "release"; var row = ActiveRow("x"); row.MergeOutcome = BatchMergeOutcome.Conflict; (string Task, string Target)? captured = null; vm.RequestConflictResolution = (taskId, target) => { captured = (taskId, target); return System.Threading.Tasks.Task.CompletedTask; }; vm.ResolveConflictCommand.Execute(row); Assert.Equal(("x", "release"), captured); } [Fact] public void MergeAll_canExecute_requires_target_selection_and_idle() { var vm = NewVm(); var a = ActiveRow("a"); vm.AddRowForTest(a); Assert.False(vm.MergeAllCommand.CanExecute(null)); // no selection, no target a.IsChecked = true; Assert.False(vm.MergeAllCommand.CanExecute(null)); // still no target vm.SelectedTarget = "main"; Assert.True(vm.MergeAllCommand.CanExecute(null)); vm.IsMerging = true; Assert.False(vm.MergeAllCommand.CanExecute(null)); // busy } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests` Expected: FAIL — `AddRowForTest`, `ResolveConflictCommand`, `MergeAllCommand` do not exist (compile error). - [ ] **Step 3: Implement subscription, commands, target loading** In `WorktreesOverviewModalViewModel.cs`: (a) Add a row-hook that recomputes `SelectedCount` when a row's `IsChecked` changes, and a test seam to add a hooked row. Add these methods to the class: ```csharp private void HookRow(WorktreeOverviewRowViewModel row) { row.PropertyChanged += (_, e) => { if (e.PropertyName is nameof(WorktreeOverviewRowViewModel.IsChecked) or nameof(WorktreeOverviewRowViewModel.State)) RecomputeSelected(); }; } private void RecomputeSelected() => SelectedCount = AllRows.Count(r => r.IsChecked && r.IsActive); // Test seam: adds a row to the flat list with selection tracking wired up. internal void AddRowForTest(WorktreeOverviewRowViewModel row) { HookRow(row); Rows.Add(row); } ``` (b) In `LoadAsync`, call `HookRow(row)` everywhere a row is added. Replace the two add sites: In the grouped branch, change `foreach (var row in grp) group.Rows.Add(row);` to: ```csharp foreach (var row in grp) { HookRow(row); group.Rows.Add(row); } ``` In the flat branch, change `foreach (var row in ordered) Rows.Add(row);` to: ```csharp foreach (var row in ordered) { HookRow(row); Rows.Add(row); } ``` Also, at the start of `LoadAsync` after `IsBusy = true;`, reset batch UI state and (re)load merge targets at the end of the `try`: After `Rows.Clear(); Groups.Clear();` add: ```csharp ConflictRows.Clear(); SelectedCount = 0; BatchProgress = null; ``` At the very end of the `try` block (after the if/else that fills rows/groups) add: ```csharp await LoadMergeTargetsAsync(); ``` (c) Add target loading. The branch list is repo-level, so query it from the first active row: ```csharp private async Task LoadMergeTargetsAsync() { var anchor = AllRows.FirstOrDefault(r => r.IsActive); if (anchor is null) { MergeTargets.Clear(); SelectedTarget = null; return; } try { var targets = await _worker.GetMergeTargetsAsync(anchor.TaskId); MergeTargets.Clear(); if (targets is null) { SelectedTarget = null; return; } foreach (var b in targets.LocalBranches) MergeTargets.Add(b); SelectedTarget = MergeTargets.Contains(targets.DefaultBranch) ? targets.DefaultBranch : MergeTargets.FirstOrDefault(); } catch { MergeTargets.Clear(); SelectedTarget = null; } } ``` (d) Add the commands: ```csharp private bool CanMergeAll() => !IsMerging && SelectedCount > 0 && !string.IsNullOrWhiteSpace(SelectedTarget); [RelayCommand(CanExecute = nameof(CanMergeAll))] private Task MergeAll() => MergeSelectedAsync(_worker.MergeTaskAsync); [RelayCommand] private void ResolveConflict(WorktreeOverviewRowViewModel? row) { if (row is null) return; RequestConflictResolution?.Invoke(row.TaskId, SelectedTarget ?? ""); } [RelayCommand] private void ToggleSelectAll() { var actives = AllRows.Where(r => r.IsActive).ToList(); var allChecked = actives.Count > 0 && actives.All(r => r.IsChecked); foreach (var r in actives) r.IsChecked = !allChecked; } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests` Expected: PASS (8 tests total in this file). - [ ] **Step 5: Build the app project to confirm the VM compiles against generated commands** Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release` Expected: Build succeeded. - [ ] **Step 6: Commit** ```bash git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs git commit -m "feat(ui): wire batch selection, target loading and resolve seam" ``` --- ## Task 4: Cockpit view — checkboxes, target picker, Merge all, conflicts panel **Files:** - Modify: `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml` This task is AXAML only (no logic) → no new unit test; flag for visual verification. - [ ] **Step 1: Add the batch toolbar controls** In `WorktreesOverviewModalView.axaml`, replace the toolbar `StackPanel` (currently containing Refresh, Cleanup finished, StatusMessage) with one that adds select-all, the target picker, the Merge-all button and progress text. Replace the inner `...` of the toolbar `Border` with: ```xml