Files
ClaudeDo/docs/superpowers/plans/2026-06-05-merge-cockpit-layer-b.md

36 KiB
Raw Permalink Blame History

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 DiffLineViewModels 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<MergeResultDto>
  • WorkerClient.GetMergeTargetsAsync(string taskId) -> Task<MergeTargetsDto?> (MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches))
  • MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage)Status is "merged" | "conflict" | "blocked" | <other>
  • 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.csmodify. 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.axamlmodify. 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.csmodify. Replace SelectedFileDiffLines element type with DiffLineViewModel produced via UnifiedDiffParser; delete WorktreeDiffLineKind and WorktreeDiffLineViewModel.
  • src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axamlmodify. Replace the right-pane ItemsControl with ctl:DiffLinesView; drop the DiffLineKindToBrushConverter resource.
  • src/ClaudeDo.Localization/locales/en.json + de.jsonmodify. Add new modals.worktreesOverview.* and vm.worktreesOverview.* keys (keep parity).
  • tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cscreate. 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:

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:

public enum BatchMergeOutcome { None, Merging, Merged, Conflict, Blocked, Failed }

Inside WorktreeOverviewRowViewModel, add after the existing _isSelected field:

    [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
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:

    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<string>(), null);
    private static MergeResultDto Conflict() => new("conflict", new[] { "f.cs" }, null);
    private static MergeResultDto Blocked() => new("blocked", System.Array.Empty<string>(), "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<string>();
        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 usings if missing: using ClaudeDo.Ui.Services; (already present). Add fields/properties to WorktreesOverviewModalViewModel (after the existing _selectedRow field):

    [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<string> MergeTargets { get; } = new();
    public ObservableCollection<WorktreeOverviewRowViewModel> ConflictRows { get; } = new();

    /// Inert seam wired by the integrator to Layer C's resolver at merge time. (taskId, targetBranch)
    public Func<string, string, Task>? RequestConflictResolution { get; set; }

Add a helper to enumerate rows regardless of grouped/flat mode, plus the orchestration method:

    public IEnumerable<WorktreeOverviewRowViewModel> AllRows =>
        IsGlobal ? Groups.SelectMany(g => g.Rows) : Rows;

    public async Task MergeSelectedAsync(
        Func<string, string, bool, string, Task<MergeResultDto>> 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
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:

    [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:

    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:

                    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:

                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:

            ConflictRows.Clear();
            SelectedCount = 0;
            BatchProgress = null;

At the very end of the try block (after the if/else that fills rows/groups) add:

            await LoadMergeTargetsAsync();

(c) Add target loading. The branch list is repo-level, so query it from the first active row:

    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:

    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
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 <StackPanel Orientation="Horizontal" Spacing="8">...</StackPanel> of the toolbar Border with:

        <StackPanel Orientation="Horizontal" Spacing="8">
          <Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.refresh}" Command="{Binding RefreshCommand}" IsEnabled="{Binding !IsBusy}"/>
          <Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.cleanupFinished}" Command="{Binding CleanupFinishedCommand}" IsEnabled="{Binding !IsBusy}"/>
          <Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.selectAll}" Command="{Binding ToggleSelectAllCommand}"/>
          <Border Width="1" Background="{DynamicResource LineBrush}" Margin="4,2"/>
          <TextBlock Text="{loc:Tr modals.worktreesOverview.targetLabel}" VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
          <ComboBox MinWidth="160"
                    ItemsSource="{Binding MergeTargets}"
                    SelectedItem="{Binding SelectedTarget, Mode=TwoWay}"/>
          <Button Classes="btn accent"
                  Content="{loc:Tr modals.worktreesOverview.mergeAll}"
                  Command="{Binding MergeAllCommand}"/>
          <TextBlock Text="{Binding SelectedCount, StringFormat='{}{0} selected'}"
                     VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
          <TextBlock Text="{Binding BatchProgress}" VerticalAlignment="Center" Margin="8,0,0,0"
                     Foreground="{DynamicResource TextDimBrush}"/>
          <TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center" Margin="8,0,0,0"
                     Foreground="{DynamicResource TextDimBrush}"/>
        </StackPanel>
  • Step 2: Add a checkbox + outcome badge to the row template

In the WorktreeRowTemplate DataTemplate, change the row Grid to add a leading checkbox column and a trailing outcome column. Replace the <Grid ColumnDefinitions="*,90,80,80">...</Grid> (the whole grid, lines for Task/State/Diff/Age) with:

        <Grid ColumnDefinitions="Auto,*,90,90,80,80">
          <CheckBox Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0"
                    IsChecked="{Binding IsChecked, Mode=TwoWay}"
                    IsEnabled="{Binding IsActive}"
                    IsVisible="{Binding IsActive}"/>
          <StackPanel Grid.Column="1" Orientation="Vertical" Spacing="2">
            <TextBlock Classes="title" Text="{Binding TaskTitle}"/>
            <StackPanel Orientation="Horizontal" Spacing="4">
              <TextBlock Classes="meta" Text="{Binding TaskStatus}"/>
              <TextBlock Classes="meta" Text="•"
                         IsVisible="{Binding !PathExistsOnDisk}"/>
              <TextBlock Classes="meta" Text="{loc:Tr modals.worktreesOverview.phantom}" Foreground="{DynamicResource StatusErrorBrush}"
                         IsVisible="{Binding !PathExistsOnDisk}"
                         ToolTip.Tip="{loc:Tr modals.worktreesOverview.phantomTooltip}"/>
            </StackPanel>
          </StackPanel>
          <TextBlock Grid.Column="2" Classes="meta" VerticalAlignment="Center"
                     Text="{Binding MergeOutcome}"
                     IsVisible="{Binding HasOutcome}"/>
          <Border Grid.Column="3" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
                  Background="{Binding State, Converter={StaticResource WorktreeStateColor}}">
            <TextBlock Classes="meta" Text="{Binding State}" Foreground="{DynamicResource TextBrush}"
                       HorizontalAlignment="Center"/>
          </Border>
          <TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
          <TextBlock Grid.Column="5" Classes="meta" Text="{Binding AgeText}" VerticalAlignment="Center"/>
        </Grid>

Then update the column-header Grid (the one with ColumnDefinitions="*,90,80,80" near the ScrollViewer top) to match the new column layout:

          <Grid ColumnDefinitions="Auto,*,90,90,80,80" Margin="12,0,12,4">
            <TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnTask}"/>
            <TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnOutcome}"/>
            <TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnState}"/>
            <TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnDiff}"/>
            <TextBlock Grid.Column="5" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnAge}"/>
          </Grid>
  • Step 3: Add the "Needs resolution" panel

Inside the content ScrollViewer's root StackPanel, at the very top (before the column-header Grid), add a conflicts panel that only shows when there are conflicts:

          <Border IsVisible="{Binding ConflictRows.Count}"
                  Background="{DynamicResource ErrorTintBrush}"
                  BorderBrush="{DynamicResource StatusErrorBrush}"
                  BorderThickness="1" CornerRadius="6" Padding="12,8" Margin="0,0,0,12">
            <StackPanel Spacing="6">
              <TextBlock Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.needsResolution}"/>
              <ItemsControl ItemsSource="{Binding ConflictRows}">
                <ItemsControl.ItemTemplate>
                  <DataTemplate x:DataType="vm:WorktreeOverviewRowViewModel">
                    <Grid ColumnDefinitions="*,Auto" Margin="0,2">
                      <TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
                                 Text="{Binding TaskTitle}"/>
                      <Button Grid.Column="1" Classes="btn"
                              Content="{loc:Tr modals.worktreesOverview.resolve}"
                              Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ResolveConflictCommand}"
                              CommandParameter="{Binding}"/>
                    </Grid>
                  </DataTemplate>
                </ItemsControl.ItemTemplate>
              </ItemsControl>
            </StackPanel>
          </Border>

IsVisible="{Binding ConflictRows.Count}" uses Avalonia's int→bool coercion (0 = false). If the build flags this, change to a value converter already present, but int→bool is supported.

  • Step 4: Build the app to verify the AXAML compiles

Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release Expected: Build succeeded (compiled bindings resolve against the new VM members).

  • Step 5: Commit
git add src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml
git commit -m "feat(ui): batch-merge cockpit view with checkboxes and conflicts panel"

Task 5: Localization keys (en + de parity)

Files:

  • Modify: src/ClaudeDo.Localization/locales/en.json

  • Modify: src/ClaudeDo.Localization/locales/de.json

  • Step 1: Add the new keys to en.json

Under modals.worktreesOverview, add:

      "columnOutcome": "RESULT",
      "selectAll": "Select all",
      "targetLabel": "Target",
      "mergeAll": "Merge all",
      "needsResolution": "NEEDS RESOLUTION",
      "resolve": "Resolve"

Under vm.worktreesOverview, add:

      "batchProgress": "Merging {0}/{1}…",
      "batchDone": "Merged {0}, {1} need resolution."
  • Step 2: Add the matching keys to de.json

Under modals.worktreesOverview:

      "columnOutcome": "ERGEBNIS",
      "selectAll": "Alle auswählen",
      "targetLabel": "Ziel",
      "mergeAll": "Alle mergen",
      "needsResolution": "ZU LÖSEN",
      "resolve": "Lösen"

Under vm.worktreesOverview:

      "batchProgress": "Merge {0}/{1}…",
      "batchDone": "{0} gemergt, {1} zu lösen."
  • Step 3: Run the localization parity test

Run: dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release Expected: PASS (en/de key parity holds).

  • Step 4: Commit
git add src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json
git commit -m "feat(i18n): add batch-merge cockpit strings (en/de)"

Task 6: Migrate WorktreeModalView diff onto DiffLinesView

Files:

  • Modify: src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs

  • Modify: src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml

  • Step 1: Switch the VM to the canonical diff model

In WorktreeModalViewModel.cs:

(a) Delete the now-dead types at the top of the file:

public enum WorktreeDiffLineKind { Header, Hunk, Added, Removed, Context }

public sealed partial class WorktreeDiffLineViewModel : ViewModelBase
{
    public required string Text { get; init; }
    public required WorktreeDiffLineKind Kind { get; init; }
}

(b) Change the collection declaration from:

    public ObservableCollection<WorktreeDiffLineViewModel> SelectedFileDiffLines { get; } = new();

to:

    public ObservableCollection<DiffLineViewModel> SelectedFileDiffLines { get; } = new();

(c) Replace the body of LoadFileDiffAsync (the foreach (var line in diff.Split('\n')) block) so it parses via UnifiedDiffParser. The method becomes:

    private async Task LoadFileDiffAsync(WorktreeNodeViewModel? node)
    {
        SelectedFileDiffLines.Clear();

        if (node is null || node.IsDirectory || string.IsNullOrEmpty(node.RelativePath))
            return;

        string diff;
        try
        {
            diff = await _git.GetFileDiffAsync(WorktreePath, BaseCommit, node.RelativePath);
        }
        catch
        {
            return;
        }

        foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(diff)))
            SelectedFileDiffLines.Add(line);
    }

(DiffLineViewModel, DiffFileViewModel, and UnifiedDiffParser are all in the same ClaudeDo.Ui.ViewModels.Modals namespace, so no new using is required.)

  • Step 2: Build to confirm the VM compiles and nothing else referenced the deleted types

Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release Expected: Build succeeded. (If a compile error names WorktreeDiffLineViewModel/WorktreeDiffLineKind outside this file or the view, that reference must be migrated too — there should be none besides WorktreeModalView.axaml, handled next.)

  • Step 3: Swap the view's inline diff for DiffLinesView

In WorktreeModalView.axaml:

(a) Remove the now-unused converter resource. Delete:

  <Window.Resources>
    <converters:DiffLineKindToBrushConverter x:Key="DiffLineKindToBrush"/>
  </Window.Resources>

(b) Replace the right-pane ScrollViewer's ItemsControl (the SelectableTextBlock template bound to SelectedFileDiffLines) with the canonical control. Replace:

          <ItemsControl ItemsSource="{Binding SelectedFileDiffLines}">
            <ItemsControl.ItemTemplate>
              <DataTemplate DataType="vm:WorktreeDiffLineViewModel">
                <SelectableTextBlock Text="{Binding Text}"
                                     FontFamily="{DynamicResource MonoFont}"
                                     FontSize="{StaticResource FontSizeMono}"
                                     Foreground="{Binding Kind, Converter={StaticResource DiffLineKindToBrush}}"
                                     TextWrapping="NoWrap"/>
              </DataTemplate>
            </ItemsControl.ItemTemplate>
          </ItemsControl>

with:

          <ctl:DiffLinesView Lines="{Binding SelectedFileDiffLines}"/>

(The xmlns:ctl="using:ClaudeDo.Ui.Views.Controls" namespace is already declared at the top of this file.)

  • Step 4: Build the app to verify the AXAML compiles

Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release Expected: Build succeeded.

  • Step 5: Commit
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml
git commit -m "refactor(ui): render worktree modal diff via canonical DiffLinesView"

Task 7: Full build + test sweep

Files: none (verification only).

  • Step 1: Build the whole app

Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release Expected: Build succeeded, 0 errors.

  • Step 2: Run the UI + localization test projects

Run: dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release Then: dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release Expected: PASS (all green, including the 8 new batch-merge tests).

  • Step 3: Flag visual-verification gaps

The cockpit toolbar/checkbox/conflicts-panel layout and the migrated WorktreeModalView diff rendering are AXAML changes that cannot be verified headlessly. Report to the user that these need a visual pass (run the app, open the worktrees overview, select several worktrees, pick a target, "Merge all", and open a worktree diff).


Self-Review Notes

  • Spec coverage: batch-merge cockpit (Tasks 14), skip-and-continue + conflict collection (Task 2), single target picker (Tasks 34), Resolve → RequestConflictResolution(taskId, targetBranch) seam left unwired (Tasks 34), WorktreeModalView diff migration to DiffLinesView (Task 6), no worker files touched, no IWorkerClient change, locales in parity (Task 5). ✔
  • No ConflictResolver reference: the seam is a bare Func<string,string,Task>?; no Layer C type is named. ✔
  • Type consistency: BatchMergeOutcome, MergeOutcome, IsConflict, HasOutcome, MergeSelectedAsync, ConflictRows, SelectedTarget, SelectedCount, IsMerging, BatchProgress, RequestConflictResolution, MergeAllCommand, ResolveConflictCommand, ToggleSelectAllCommand, AddRowForTest, AllRows are used consistently across tasks. ✔