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
@@ -98,4 +98,43 @@ public class DetailsIslandReviewActionsTests : IDisposable
vm.ReviewFeedback = " ";
Assert.False(vm.RejectReviewCommand.CanExecute(null));
}
[Fact]
public void Approve_IsEnabled_WhenThereIsNothingToReview()
{
// A childless sandbox run has no worktree diff to inspect, so the gate must
// not block it — it approves straight through.
var vm = BuildVm(new RecordingWorkerClient());
vm.Bind(new TaskRowViewModel { Id = "task-nodiff-1", Status = TaskStatus.WaitingForReview });
vm.Monitor.ApplyState(TaskStatus.WaitingForReview);
Assert.False(vm.Merge.HasReviewableDiff);
Assert.False(vm.ShowReviewDiffHint);
Assert.True(vm.ApproveReviewCommand.CanExecute(null));
}
[Fact]
public void Approve_IsGatedUntilDiffOpened_AndReLocksOnNewRun()
{
var vm = BuildVm(new RecordingWorkerClient());
vm.Bind(new TaskRowViewModel { Id = "task-diff-1", Status = TaskStatus.WaitingForReview });
vm.Merge.SyncWorktree("/tmp/wt", null, null, "Active", null);
vm.Monitor.ApplyState(TaskStatus.WaitingForReview);
// There is a diff to read, but it has not been opened → merge is blocked.
Assert.True(vm.Merge.HasReviewableDiff);
Assert.True(vm.ShowReviewDiffHint);
Assert.False(vm.ApproveReviewCommand.CanExecute(null));
// Opening the diff records the inspection and unlocks the merge.
vm.Merge.DiffViewed?.Invoke();
Assert.False(vm.ShowReviewDiffHint);
Assert.True(vm.ApproveReviewCommand.CanExecute(null));
// A new run means a fresh diff — the gate re-engages.
vm.Monitor.ApplyState(TaskStatus.Running);
vm.Monitor.ApplyState(TaskStatus.WaitingForReview);
Assert.True(vm.ShowReviewDiffHint);
Assert.False(vm.ApproveReviewCommand.CanExecute(null));
}
}