diff --git a/src/ClaudeDo.Ui/Design/IslandStyles.axaml b/src/ClaudeDo.Ui/Design/IslandStyles.axaml index 84eeeb4..dc8645f 100644 --- a/src/ClaudeDo.Ui/Design/IslandStyles.axaml +++ b/src/ClaudeDo.Ui/Design/IslandStyles.axaml @@ -88,6 +88,10 @@ M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z + + M9 5 L16 12 L9 19 L6.8 16.8 L11.6 12 L6.8 7.2 Z + M5 9 L12 16 L19 9 L16.8 6.8 L12 11.6 L7.2 6.8 Z + M4 6 H20 V8 H4 Z M4 11 H20 V13 H4 Z M4 16 H14 V18 H4 Z diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index e7c7fde..fa40b2d 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -301,27 +301,17 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable internal void Regroup() { - OverdueItems.Clear(); - OpenItems.Clear(); - CompletedItems.Clear(); - - // Auto-collapse planning parents whose every child is Done (unless the user - // has explicitly toggled the row — saved state wins). + // Collapse parents that have children by default, so subtasks stay tucked away until + // the user expands the row (an explicit toggle is saved and wins over this default). var childrenByParent = Items .Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId)) .GroupBy(r => r.ParentTaskId!) .ToDictionary(g => g.Key, g => g.ToList()); - foreach (var parent in Items.Where(r => r.IsPlanningParent && !r.IsChild - && r.PlanningPhase == PlanningPhase.Finalized - && !r.Done)) + foreach (var parent in Items.Where(r => r.IsPlanningParent && !r.IsChild && !r.Done)) { if (_expandedState.ContainsKey(parent.Id)) continue; - if (childrenByParent.TryGetValue(parent.Id, out var kids) - && kids.Count > 0 - && kids.All(c => c.Status == TaskStatus.Done)) - { + if (childrenByParent.TryGetValue(parent.Id, out var kids) && kids.Count > 0) parent.IsExpanded = false; - } } // Restore IsExpanded from saved state @@ -362,19 +352,29 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable } var today = DateTime.Today; + var overdue = new List(); + var open = new List(); + var completed = new List(); foreach (var r in flat) { var underOpenPlanningParent = r.IsChild && flat.Any(p => p.Id == r.ParentTaskId && p.IsPlanningParent && !p.Done); if (r.Done && !underOpenPlanningParent) - CompletedItems.Add(r); + completed.Add(r); else if (r.ScheduledFor is { } d && d.Date < today) - OverdueItems.Add(r); + overdue.Add(r); else - OpenItems.Add(r); + open.Add(r); } + // Reconcile the bound collections in place (granular Insert/Move/Remove) rather than + // Clear+Add, so toggling a parent only touches its own child rows — the ItemsControl + // keeps every unchanged container instead of tearing the whole list down on a Reset. + SyncCollection(OverdueItems, overdue); + SyncCollection(OpenItems, open); + SyncCollection(CompletedItems, completed); + HasOverdue = OverdueItems.Count > 0; HasOpen = OpenItems.Count > 0; HasCompleted = CompletedItems.Count > 0; @@ -500,6 +500,28 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable coll.Move(srcIdx, finalIdx); } + // Reconcile a bound collection toward a target order using granular Remove/Move/Insert, + // so unchanged rows keep their containers (no Reset-driven full re-render). + private static void SyncCollection( + System.Collections.ObjectModel.ObservableCollection dst, + List target) + { + var keep = new HashSet(target); + for (int i = dst.Count - 1; i >= 0; i--) + if (!keep.Contains(dst[i])) + dst.RemoveAt(i); + + for (int i = 0; i < target.Count; i++) + { + var item = target[i]; + if (i < dst.Count && ReferenceEquals(dst[i], item)) continue; + + var cur = dst.IndexOf(item); + if (cur >= 0) dst.Move(cur, i); + else dst.Insert(i, item); + } + } + private System.Collections.ObjectModel.ObservableCollection? SectionFor(TaskRowViewModel row) { if (OverdueItems.Contains(row)) return OverdueItems; diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml index 6757347..c4a5cf6 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml @@ -87,10 +87,10 @@ VerticalAlignment="Center" ToolTip.Tip="{loc:Tr tasks.toggleSubtasksTip}"> - - + + diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs index b54da10..e99c74c 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs @@ -1,12 +1,7 @@ using System.Linq; -using Avalonia; -using Avalonia.Animation; -using Avalonia.Animation.Easings; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Interactivity; -using Avalonia.Media; -using Avalonia.Styling; using Avalonia.VisualTree; using ClaudeDo.Data.Models; using ClaudeDo.Ui.ViewModels.Islands; @@ -123,24 +118,4 @@ public partial class TaskRowView : UserControl ScheduleAnchor.Flyout?.Hide(); _pendingScheduleRow = null; } - - protected override async void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) - { - base.OnAttachedToVisualTree(e); - RenderTransform = new TranslateTransform(0, 8); - Opacity = 0; - var anim = new Avalonia.Animation.Animation - { - Duration = TimeSpan.FromMilliseconds(300), - Easing = new CubicEaseOut(), - Children = - { - new KeyFrame { Cue = new Cue(0), Setters = { new Setter(OpacityProperty, 0d) } }, - new KeyFrame { Cue = new Cue(1), Setters = { new Setter(OpacityProperty, 1d) } }, - } - }; - await anim.RunAsync(this); - Opacity = 1; - RenderTransform = null; - } } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRegroupTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRegroupTests.cs index 7ced49e..3598dfc 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRegroupTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRegroupTests.cs @@ -168,6 +168,9 @@ public class TasksIslandRegroupTests : IDisposable var vm = BuildViewModel(); await LoadAndWaitAsync(vm, UserList("list1", "Default")); + // Parents with children collapse by default; expand to surface the nested child. + vm.ToggleExpandCommand.Execute(vm.Items.First(r => r.Id == "p1")); + // Child with Done status under an open Planning parent should NOT go to CompletedItems Assert.DoesNotContain(vm.CompletedItems, r => r.Id == "c1"); // Child should appear nested (IsChild == true) in OpenItems diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index 8bfff79..1aa3b11 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -165,7 +165,7 @@ public class TasksIslandViewModelPlanningTests => new TaskRowViewModel { Id = id, Status = status, ParentTaskId = parentId, PlanningPhase = phase }; [Fact] - public void ToggleExpand_CollapsesChildrenOfPlanningParent() + public void PlanningParentWithChildren_CollapsedByDefault_ToggleExpands() { var parent = MakeRow("p1", TaskStatus.Idle, phase: PlanningPhase.Active); var child1 = MakeRow("c1", TaskStatus.Idle, "p1"); @@ -173,18 +173,17 @@ public class TasksIslandViewModelPlanningTests var (vm, _) = VmFactory.Create([parent, child1, child2]); - // Initially expanded — children visible in OpenItems - Assert.Contains(child1, vm.OpenItems); - Assert.Contains(child2, vm.OpenItems); - - // Collapse the parent - vm.ToggleExpandCommand.Execute(parent); - - // Children should no longer appear + // Collapsed by default — children hidden, parent still present Assert.DoesNotContain(child1, vm.OpenItems); Assert.DoesNotContain(child2, vm.OpenItems); - // Parent still present Assert.Contains(parent, vm.OpenItems); + + // Expand the parent + vm.ToggleExpandCommand.Execute(parent); + + // Children now visible + Assert.Contains(child1, vm.OpenItems); + Assert.Contains(child2, vm.OpenItems); } [Fact] @@ -210,20 +209,23 @@ public class TasksIslandViewModelPlanningTests } [Fact] - public void ToggleExpand_ExpandsCollapsedParentAgain() + public void ToggleExpand_TogglesParentExpansion() { var parent = MakeRow("p1", TaskStatus.Idle, phase: PlanningPhase.Finalized); var child = MakeRow("c1", TaskStatus.Idle, "p1"); var (vm, _) = VmFactory.Create([parent, child]); - // Collapse - vm.ToggleExpandCommand.Execute(parent); + // Collapsed by default Assert.DoesNotContain(child, vm.OpenItems); - // Re-expand + // Expand vm.ToggleExpandCommand.Execute(parent); Assert.Contains(child, vm.OpenItems); + + // Collapse again + vm.ToggleExpandCommand.Execute(parent); + Assert.DoesNotContain(child, vm.OpenItems); } [Fact]