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()
{