feat(ui): show mergeability and surface approve conflicts in the work console

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-06-04 23:35:53 +02:00
parent 3202c76674
commit 0d8999dc20
3 changed files with 177 additions and 4 deletions

View File

@@ -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 */ }
}