feat(review): gate Approve & Merge behind opening the diff

Approve & Merge is disabled until the pending changes have been inspected:
DetailsIslandViewModel tracks ReviewDiffViewed (reset on task switch and on
every state change), MergeSectionViewModel raises DiffViewed when a diff or
combined diff is opened and exposes HasReviewableDiff, and a hint sits next
to the button. A review with nothing to inspect (sandbox run, no worktree)
approves straight through.

ClaudeDo-Task: f9809a93
This commit is contained in:
mika kuns
2026-07-24 12:52:57 +02:00
parent 3e9ea3ad58
commit 2aaaa23912
7 changed files with 108 additions and 3 deletions
@@ -296,6 +296,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
AgentSettings = new AgentConfigEditorViewModel(worker, AgentConfigScope.Task);
Merge = new MergeSectionViewModel(worker, services);
Merge.DiffViewed = () => ReviewDiffViewed = true;
Merge.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(MergeSectionViewModel.HasReviewableDiff))
{
ApproveReviewCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(ShowReviewDiffHint));
}
};
Prep = new PrepPanelViewModel(worker);
Notes = new NotesEditorViewModel(_notesApi);
@@ -399,6 +408,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
DequeueCommand.NotifyCanExecuteChanged();
ResetAndRetryCommand.NotifyCanExecuteChanged();
ContinueCommand.NotifyCanExecuteChanged();
// A state change means a new run/review cycle: the diff must be
// re-inspected before merge can be approved again.
ReviewDiffViewed = false;
ApproveReviewCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(ShowReviewDiffHint));
AgentSettings.IsRunning = IsRunning;
NotifySessionSections();
OnPropertyChanged(nameof(CanAcceptDrop));
@@ -752,6 +766,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
partial void OnTaskChanged(TaskRowViewModel? value)
{
ReviewDiffViewed = false;
Merge.SyncTaskContext(Task?.Id, Task?.Title, Task?.IsPlanningParent == true);
NotifySessionSections();
OnPropertyChanged(nameof(CanAcceptDrop));
@@ -981,7 +996,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private bool CanResetAndRetry() =>
Task != null && _worker.IsConnected && ShowResetAndRetry;
[RelayCommand]
// Set once the user opens the diff/combined-diff for the current review. Reset on
// task switch and on every state change (a new run means a new diff to read), so
// Approve & Merge stays blocked until the pending changes have been inspected.
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ApproveReviewCommand))]
[NotifyPropertyChangedFor(nameof(ShowReviewDiffHint))]
private bool _reviewDiffViewed;
// True while a review is pending, there is a diff to inspect, and it has not been
// opened yet — drives the "open the diff first" hint next to Approve & Merge.
public bool ShowReviewDiffHint =>
IsWaitingForReview && Merge.HasReviewableDiff && !ReviewDiffViewed;
[RelayCommand(CanExecute = nameof(CanApproveReview))]
private async System.Threading.Tasks.Task ApproveReviewAsync()
{
if (Task is null || !_worker.IsConnected) return;
@@ -999,6 +1027,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
}
}
// Force the diff to have been opened before a merge can happen — but only when
// there is actually something to inspect (a childless sandbox run with no worktree
// has no diff, so it approves straight through).
private bool CanApproveReview() =>
Task != null && _worker.IsConnected && IsWaitingForReview
&& (!Merge.HasReviewableDiff || ReviewDiffViewed);
[RelayCommand(CanExecute = nameof(HasReviewFeedback))]
private async System.Threading.Tasks.Task RejectReviewAsync()
{
@@ -48,6 +48,15 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
public Func<DiffViewerViewModel, System.Threading.Tasks.Task>? ShowDiffViewer { get; set; }
public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }
// Raised when the user opens a diff/combined-diff for the current task, so the
// review gate can record that the changes were inspected before merging.
public Action? DiffViewed { get; set; }
// True when there is something to inspect before merging (a live worktree diff,
// a merged commit range, or a planning/children combined diff). When false there
// is nothing to read, so the review gate must not block approve.
public bool HasReviewableDiff => CanOpenDiff() || CanReviewDiff();
public MergeSectionViewModel(IWorkerClient worker, IServiceProvider services)
{
_worker = worker;
@@ -69,6 +78,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
_worktreeStateLabel = worktreeState;
_listWorkingDir = listWorkDir;
OnPropertyChanged(nameof(ShowMergeSection));
OnPropertyChanged(nameof(HasReviewableDiff));
OpenDiffCommand.NotifyCanExecuteChanged();
OpenWorktreeCommand.NotifyCanExecuteChanged();
}
@@ -79,6 +89,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
TaskTitle = taskTitle;
_isPlanningParent = isPlanningParent;
OnPropertyChanged(nameof(ShowMergeSection));
OnPropertyChanged(nameof(HasReviewableDiff));
}
internal void SyncChildOutcomes(bool hasChildOutcomes, int subtaskCount)
@@ -86,6 +97,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
_hasChildOutcomes = hasChildOutcomes;
_subtaskCount = subtaskCount;
OnPropertyChanged(nameof(ShowMergeSection));
OnPropertyChanged(nameof(HasReviewableDiff));
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
}
@@ -128,6 +140,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
var vm = _services.GetRequiredService<DiffViewerViewModel>();
vm.ConfigurePlanning(TaskId, SelectedMergeTarget ?? "main");
await vm.LoadAsync();
DiffViewed?.Invoke();
await ShowDiffViewer(vm);
}
@@ -157,6 +170,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
else return;
await vm.LoadAsync();
DiffViewed?.Invoke();
await ShowDiffViewer(vm);
}