From 0d8999dc20a3994208573cd83354c18c01c3e4a4 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 4 Jun 2026 23:35:53 +0200 Subject: [PATCH] feat(ui): show mergeability and surface approve conflicts in the work console Co-Authored-By: Claude Sonnet 4.6 --- .../Islands/DetailsIslandViewModel.cs | 91 ++++++++++++++++++- .../Islands/MergePreviewPresenter.cs | 28 ++++++ .../ViewModels/MergePreviewPresenterTests.cs | 62 +++++++++++++ 3 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index d2052d7..15f8c35 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -339,6 +339,24 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase [ObservableProperty] private string? _mergeAllDisabledReason; [ObservableProperty] private string? _mergeAllError; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))] + private string _mergePreviewText = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))] + private bool _mergeIsClean; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))] + private bool _mergeIsConflict; + + public bool ShowMergePreviewMuted => + !MergeIsClean && !MergeIsConflict && !string.IsNullOrEmpty(MergePreviewText); + + public bool ShowSingleMerge => + WorktreePath != null && Task?.IsPlanningParent != true; + // Claude CLI stream-json parser + buffer for partial text deltas private readonly StreamLineFormatter _formatter = new(); private readonly StringBuilder _claudeBuf = new(); @@ -812,6 +830,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase { await LoadChildOutcomesAsync(row.Id, ct); } + + if (entity.Worktree != null + && entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None + && MergeTargetBranches.Count == 0) + { + var targets = await _worker.GetMergeTargetsAsync(row.Id); + if (targets != null) + { + MergeTargetBranches.Clear(); + foreach (var b in targets.LocalBranches) MergeTargetBranches.Add(b); + SelectedMergeTarget = targets.DefaultBranch; // triggers OnSelectedMergeTargetChanged → preview + } + } + await RefreshMergePreviewAsync(); } catch (OperationCanceledException) { } } @@ -1102,6 +1134,45 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase catch { /* best-effort refresh */ } } + private async System.Threading.Tasks.Task RefreshMergePreviewAsync() + { + if (Task is null || WorktreePath is null) + { + MergePreviewText = ""; MergeIsClean = false; MergeIsConflict = false; + return; + } + // Only probe Active worktrees; terminal states show their label instead. + if (WorktreeStateLabel is { } label && label != "Active") + { + MergePreviewText = label; MergeIsClean = false; MergeIsConflict = false; + return; + } + var dto = await _worker.PreviewMergeAsync(Task.Id, SelectedMergeTarget ?? ""); + var (text, clean, conflict) = MergePreviewPresenter.Describe(dto); + MergePreviewText = text; MergeIsClean = clean; MergeIsConflict = conflict; + } + + [RelayCommand] + private async System.Threading.Tasks.Task MergeAsync() + { + if (Task is null || WorktreePath is null || !_worker.IsConnected) return; + try + { + var result = await _worker.MergeTaskAsync(Task.Id, SelectedMergeTarget ?? "", false, "Merge task"); + if (result.Status == "conflict") + { + var (text, _, _) = MergePreviewPresenter.Describe( + new MergePreviewDto("conflict", result.ConflictFiles, 0)); + MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true; + } + else + { + await RefreshMergePreviewAsync(); + } + } + catch { /* broadcast reconciles */ } + } + [RelayCommand(CanExecute = nameof(CanOpenDiff))] private async System.Threading.Tasks.Task OpenDiffAsync() { @@ -1138,11 +1209,17 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase private bool CanOpenWorktree() => WorktreePath != null; + partial void OnSelectedMergeTargetChanged(string? value) + { + _ = RefreshMergePreviewAsync(); + } + partial void OnWorktreePathChanged(string? value) { OpenDiffCommand.NotifyCanExecuteChanged(); OpenWorktreeCommand.NotifyCanExecuteChanged(); NotifySessionSections(); + OnPropertyChanged(nameof(ShowSingleMerge)); } partial void OnTaskChanged(TaskRowViewModel? value) => NotifySessionSections(); @@ -1362,10 +1439,16 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase private async System.Threading.Tasks.Task ApproveReviewAsync() { if (Task is null || !_worker.IsConnected) return; - // The hub rejects (HubException) if the task is no longer WaitingForReview - // — e.g. after "Merge all" folded the parent. Swallow it; the TaskUpdated - // broadcast reconciles the UI. An unhandled command exception would crash. - try { await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? ""); } + try + { + var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? ""); + if (result?.Status == "conflict") + { + var (text, _, _) = MergePreviewPresenter.Describe( + new MergePreviewDto("conflict", result.ConflictFiles, 0)); + MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true; + } + } catch { /* stale review action; broadcast reconciles */ } } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs b/src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs new file mode 100644 index 0000000..605ff68 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs @@ -0,0 +1,28 @@ +using System.Linq; +using ClaudeDo.Ui.Services; + +namespace ClaudeDo.Ui.ViewModels.Islands; + +/// Pure mapping from a merge-preview DTO to display text + color flags. +public static class MergePreviewPresenter +{ + public static (string Text, bool IsClean, bool IsConflict) Describe(MergePreviewDto? dto) + { + if (dto is null) return ("", false, false); + + switch (dto.Status) + { + case "clean": + var unit = dto.ChangedFileCount == 1 ? "file" : "files"; + return ($"Merges cleanly · {dto.ChangedFileCount} {unit}", true, false); + + case "conflict": + var names = string.Join(", ", dto.ConflictFiles.Take(3)); + var more = dto.ConflictFiles.Count > 3 ? $" (+{dto.ConflictFiles.Count - 3} more)" : ""; + return ($"Conflicts in {names}{more}", false, true); + + default: + return ("Mergeability unknown", false, false); + } + } +} diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs new file mode 100644 index 0000000..327c6b3 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs @@ -0,0 +1,62 @@ +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Islands; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public class MergePreviewPresenterTests +{ + [Fact] + public void Clean_Plural() + { + var (text, clean, conflict) = MergePreviewPresenter.Describe( + new MergePreviewDto("clean", System.Array.Empty(), 3)); + Assert.Equal("Merges cleanly · 3 files", text); + Assert.True(clean); + Assert.False(conflict); + } + + [Fact] + public void Clean_Singular() + { + var (text, _, _) = MergePreviewPresenter.Describe( + new MergePreviewDto("clean", System.Array.Empty(), 1)); + Assert.Equal("Merges cleanly · 1 file", text); + } + + [Fact] + public void Conflict_ListsUpToThree() + { + var (text, clean, conflict) = MergePreviewPresenter.Describe( + new MergePreviewDto("conflict", new[] { "a.cs", "b.cs" }, 0)); + Assert.Equal("Conflicts in a.cs, b.cs", text); + Assert.False(clean); + Assert.True(conflict); + } + + [Fact] + public void Conflict_TruncatesWithMore() + { + var (text, _, _) = MergePreviewPresenter.Describe( + new MergePreviewDto("conflict", new[] { "a", "b", "c", "d", "e" }, 0)); + Assert.Equal("Conflicts in a, b, c (+2 more)", text); + } + + [Fact] + public void Unavailable_IsMuted() + { + var (text, clean, conflict) = MergePreviewPresenter.Describe( + new MergePreviewDto("unavailable", System.Array.Empty(), 0)); + Assert.Equal("Mergeability unknown", text); + Assert.False(clean); + Assert.False(conflict); + } + + [Fact] + public void Null_IsEmpty() + { + var (text, clean, conflict) = MergePreviewPresenter.Describe(null); + Assert.Equal("", text); + Assert.False(clean); + Assert.False(conflict); + } +}