diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index 0c4d324e..cc7d4dfc 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -158,22 +158,26 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable { var snapshot = await _worker.GetActiveOperationsAsync(); RunningOperations.Clear(); - foreach (var op in snapshot.OrderBy(o => o.StartedAt)) + foreach (var op in snapshot.Where(o => !_endedOperationIds.Contains(o.Id)).OrderBy(o => o.StartedAt)) RunningOperations.Add(new RunningOperationViewModel(op)); NotifyOperationsChanged(); } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.operationsRefreshFailed", ex.Message)); } } + /// Ids already reported ended; guards against a late Started or a stale snapshot re-adding them. Op ids are unique, so it is never pruned (a few bytes per op). + private readonly HashSet _endedOperationIds = new(); + private void AddOperation(RunningOperationDto op) { - if (RunningOperations.Any(r => r.Id == op.Id)) return; + if (_endedOperationIds.Contains(op.Id) || RunningOperations.Any(r => r.Id == op.Id)) return; RunningOperations.Add(new RunningOperationViewModel(op)); NotifyOperationsChanged(); } private void RemoveOperation(string id) { + _endedOperationIds.Add(id); var row = RunningOperations.FirstOrDefault(r => r.Id == id); if (row is null) return; RunningOperations.Remove(row); diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlRunningOperationsTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlRunningOperationsTests.cs index cc82b408..e1304b25 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlRunningOperationsTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlRunningOperationsTests.cs @@ -28,7 +28,13 @@ public class MissionControlRunningOperationsTests : IDisposable private sealed class FakeWorker : StubWorkerClient { public IReadOnlyList Snapshot { get; set; } = Array.Empty(); - public override Task> GetActiveOperationsAsync() => Task.FromResult(Snapshot); + /// When set, the snapshot call completes only once this source is resolved. + public TaskCompletionSource? Gate { get; set; } + public override async Task> GetActiveOperationsAsync() + { + if (Gate is not null) await Gate.Task; + return Snapshot; + } } private static RunningOperationDto Op(string id, string kind, int? number, string title, string? listId = null) => @@ -78,6 +84,35 @@ public class MissionControlRunningOperationsTests : IDisposable Assert.False(vm.HasRunningOperations); } + [Fact] + public async Task EndedDuringPendingSnapshot_DoesNotReappear() + { + var gate = new TaskCompletionSource(); + var worker = new FakeWorker { Snapshot = new[] { Op("a", OperationKinds.Merging, 1, "Task") }, Gate = gate }; + var vm = BuildVm(worker); + + var refresh = vm.RefreshOperationsAsync(); + worker.RaiseOperationEnded("a"); + gate.SetResult(); + await refresh; + + Assert.Empty(vm.RunningOperations); + Assert.False(vm.HasRunningOperations); + } + + [Fact] + public async Task EndedBeforeStarted_LeavesNoRow() + { + var worker = new FakeWorker(); + var vm = BuildVm(worker); + await vm.RefreshOperationsAsync(); + + worker.RaiseOperationEnded("a"); + worker.RaiseOperationStarted(Op("a", OperationKinds.Merging, 1, "Task")); + + Assert.Empty(vm.RunningOperations); + } + [Fact] public async Task ConnectionRestored_ReplacesListWithFreshSnapshot() {