From 3060cb02422697ddec4f8d935d4d7a872c791489 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 5 Jun 2026 10:42:02 +0200 Subject: [PATCH] docs(plan): Layer B multi-worktree merge cockpit plan --- .../plans/2026-06-05-merge-cockpit-layer-b.md | 837 ++++++++++++++++++ 1 file changed, 837 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-05-merge-cockpit-layer-b.md diff --git a/docs/superpowers/plans/2026-06-05-merge-cockpit-layer-b.md b/docs/superpowers/plans/2026-06-05-merge-cockpit-layer-b.md new file mode 100644 index 0000000..c6a428f --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-merge-cockpit-layer-b.md @@ -0,0 +1,837 @@ +# 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 + +