diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 14da506..2ed2ae9 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -1267,7 +1267,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase private async System.Threading.Tasks.Task ApproveReviewAsync() { if (Task is null || !_worker.IsConnected) return; - await _worker.ApproveReviewAsync(Task.Id); + // 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); } + catch { /* stale review action; broadcast reconciles */ } } [RelayCommand] @@ -1276,7 +1280,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase if (Task is null || !_worker.IsConnected) return; var feedback = ReviewFeedback; if (string.IsNullOrWhiteSpace(feedback)) return; - await _worker.RejectReviewToQueueAsync(Task.Id, feedback); + try { await _worker.RejectReviewToQueueAsync(Task.Id, feedback); } + catch { /* stale review action; broadcast reconciles */ return; } ReviewFeedback = ""; } @@ -1284,14 +1289,16 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase private async System.Threading.Tasks.Task ParkReviewAsync() { if (Task is null || !_worker.IsConnected) return; - await _worker.RejectReviewToIdleAsync(Task.Id); + try { await _worker.RejectReviewToIdleAsync(Task.Id); } + catch { /* stale review action; broadcast reconciles */ } } [RelayCommand] private async System.Threading.Tasks.Task CancelReviewAsync() { if (Task is null || !_worker.IsConnected) return; - await _worker.CancelReviewAsync(Task.Id); + try { await _worker.CancelReviewAsync(Task.Id); } + catch { /* stale review action; broadcast reconciles */ } } // ── Diff meter parser ─────────────────────────────────────────────────────── diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs index ab68b3f..6f00e17 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs @@ -63,12 +63,21 @@ public class DetailsIslandPlanningTests : IDisposable public Task DeleteAsync(string id) => Task.CompletedTask; } - private DetailsIslandViewModel BuildVm(FakeWorkerClient worker) + private DetailsIslandViewModel BuildVm(StubWorkerClient worker) { var factory = new TestDbFactory(NewContext); return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new StubNotesApi()); } + // Connected worker whose review calls fail the way the hub does when the task + // is no longer WaitingForReview (e.g. after "Merge all" folded the parent). + private sealed class ThrowingReviewWorkerClient : StubWorkerClient + { + public override bool IsConnected => true; + public override Task ApproveReviewAsync(string taskId) => + Task.FromException(new InvalidOperationException("Task is not waiting for review; cannot approve.")); + } + private static SubtaskRowViewModel MakeSubtask(TaskStatus status, WorktreeState wt = WorktreeState.Active) => new() { Id = Guid.NewGuid().ToString(), Title = "t", Status = status, WorktreeState = wt }; @@ -135,6 +144,21 @@ public class DetailsIslandPlanningTests : IDisposable vm.MergeAllDisabledReason.Contains("discarded", StringComparison.OrdinalIgnoreCase)); } + // ── Review-action resilience: a failing hub call must not crash the app ─── + + [Fact] + public async Task ApproveReview_WhenWorkerThrows_DoesNotPropagate() + { + var vm = BuildVm(new ThrowingReviewWorkerClient()); + vm.Bind(new TaskRowViewModel { Id = "p", Status = TaskStatus.WaitingForReview }); + + // Before the fix this surfaced the HubException as an unobserved + // async-void exception from the command, crashing the process. + var ex = await Record.ExceptionAsync(() => vm.ApproveReviewCommand.ExecuteAsync(null)); + + Assert.Null(ex); + } + // ── Branch-load test exercising the VM via Bind ────────────────────────── [Fact]