diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs index 3885b5b0..95a06766 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs @@ -41,6 +41,10 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase // Parent id -> its child rows, built after load; drives the tick-parent-ticks-children cascade. private Dictionary> _childrenByParentId = new(); + // Set while ReconcileTickAsync restores remembered ticks, so replaying a parent's state + // doesn't cascade over its children's own remembered state. + private bool _suppressCascade; + public ObservableCollection Tasks { get; } = new(); [ObservableProperty] private string _scopeLabel = ""; @@ -70,16 +74,31 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase ScopeLabel = Loc.T("modals.mergeHelper.scopeList", listName); } - // LoadAsync rebuilds every row from scratch, which would wipe the user's ticks out from - // under them on every tick — capture and restore selection by id around the reload. + // LoadAsync rebuilds every row from scratch and re-applies IsTickedByDefault, which would wipe + // the user's ticks out from under them on every tick — in BOTH directions. Remembering only + // the ticked ids would silently re-tick a row the user deliberately unticked (Idle/Queued/ + // WaitingForReview/Failed all default to ticked), so carry the full state of every row that + // already existed across the reload; only genuinely new rows get the default. internal async Task ReconcileTickAsync() { if (string.IsNullOrEmpty(_listId)) return; - var selected = Tasks.Where(t => t.IsSelected).Select(t => t.Id).ToHashSet(); + var previous = Tasks.ToDictionary(t => t.Id, t => t.IsSelected); await LoadAsync(); - foreach (var t in Tasks) - if (selected.Contains(t.Id)) - t.IsSelected = true; + + // Restore without the parent→children cascade: it would overwrite a child's own remembered + // state with its parent's, undoing exactly the per-child choice being restored. + _suppressCascade = true; + try + { + foreach (var t in Tasks) + if (previous.TryGetValue(t.Id, out var wasSelected)) + t.IsSelected = wasSelected; + } + finally + { + _suppressCascade = false; + } + OnPropertyChanged(nameof(CanConfirm)); } @@ -162,7 +181,8 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase // Ticking/unticking a parent cascades to its children; a child can still be // deselected on its own afterwards without affecting the parent's checkbox — // ClaudeDo has no indeterminate/tri-state parent checkbox. - if (sender is MergeHelperTaskRowViewModel row + if (!_suppressCascade + && sender is MergeHelperTaskRowViewModel row && _childrenByParentId.TryGetValue(row.Id, out var children)) { foreach (var child in children) child.IsSelected = row.IsSelected; diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs index 6ac46bc0..27737d02 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs @@ -89,6 +89,73 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable Assert.False(vm.Tasks.Single(t => t.Id == "t-children").IsSelected); } + [Fact] + public async Task ReconcileTick_KeepsBothTicksAndUnticks_AndDefaultsOnlyNewRows() + { + await SeedAllStatusesAsync(); + var vm = BuildVm(); + vm.Configure("L1", "Work"); + await vm.LoadAsync(); + + // The user's own choices, both directions, against the defaults. + vm.Tasks.Single(t => t.Id == "t-idle").IsSelected = false; // default true -> off + vm.Tasks.Single(t => t.Id == "t-running").IsSelected = true; // default false -> on + + await using (var db = NewContext()) + { + db.Tasks.Add(new TaskEntity + { + Id = "t-new", ListId = "L1", Title = "arrived later", + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 99, + }); + await db.SaveChangesAsync(); + } + + await vm.ReconcileTickAsync(); + + Assert.False(vm.Tasks.Single(t => t.Id == "t-idle").IsSelected); + Assert.True(vm.Tasks.Single(t => t.Id == "t-running").IsSelected); + Assert.True(vm.Tasks.Single(t => t.Id == "t-queued").IsSelected); // untouched, keeps default + Assert.True(vm.Tasks.Single(t => t.Id == "t-new").IsSelected); // new row gets the default + } + + [Fact] + public async Task ReconcileTick_DoesNotCascadeParentStateOverAnUntickedChild() + { + await using (var db = NewContext()) + { + db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); + db.Tasks.Add(new TaskEntity + { + Id = "parent", ListId = "L1", Title = "parent", + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 0, + }); + db.Tasks.Add(new TaskEntity + { + Id = "child1", ListId = "L1", Title = "child1", ParentTaskId = "parent", + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 1, + }); + db.Tasks.Add(new TaskEntity + { + Id = "child2", ListId = "L1", Title = "child2", ParentTaskId = "parent", + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 2, + }); + await db.SaveChangesAsync(); + } + + var vm = BuildVm(); + vm.Configure("L1", "Work"); + await vm.LoadAsync(); + + vm.Tasks.Single(t => t.Id == "child1").IsSelected = false; // parent stays ticked + + await vm.ReconcileTickAsync(); + + Assert.True(vm.Tasks.Single(t => t.Id == "parent").IsSelected); + Assert.False(vm.Tasks.Single(t => t.Id == "child1").IsSelected); + Assert.True(vm.Tasks.Single(t => t.Id == "child2").IsSelected); + } + [Fact] public async Task Load_PerListScope_FiltersToThatList() {