diff --git a/src/ClaudeDo.Ui/Converters/NotHeaderRowConverter.cs b/src/ClaudeDo.Ui/Converters/NotHeaderRowConverter.cs
new file mode 100644
index 00000000..9af97f28
--- /dev/null
+++ b/src/ClaudeDo.Ui/Converters/NotHeaderRowConverter.cs
@@ -0,0 +1,18 @@
+using System.Globalization;
+using Avalonia.Data.Converters;
+using ClaudeDo.Ui.ViewModels.Islands;
+
+namespace ClaudeDo.Ui.Converters;
+
+// Drives ListBoxItem.Focusable for TasksIslandView's flat Rows list: a HeaderRow container must
+// not be a tab stop, a TaskRowViewModel container must behave like any other row.
+public class NotHeaderRowConverter : IValueConverter
+{
+ public static NotHeaderRowConverter Instance { get; } = new();
+
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => value is not HeaderRow;
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
index 2d86ce68..967a2e67 100644
--- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
@@ -15,6 +15,19 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels.Islands;
+/// A group header entry in — the flat list's
+/// other union member alongside . Instances are persistent (one per
+/// section, mutated in place by ) so an unchanged header
+/// keeps its identity across a reconcile.
+public sealed partial class HeaderRow : ViewModelBase
+{
+ [ObservableProperty] private string _label = "";
+ [ObservableProperty] private int _count;
+ public bool IsOverdue { get; init; }
+ public IRelayCommand? ActionCommand { get; init; }
+ public bool HasAction => ActionCommand is not null;
+}
+
public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{
private readonly IDbContextFactory _dbFactory;
@@ -60,9 +73,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
}
public ObservableCollection Items { get; } = new();
- public ObservableCollection OverdueItems { get; } = new();
- public ObservableCollection OpenItems { get; } = new();
- public ObservableCollection CompletedItems { get; } = new();
+
+ /// Flat, virtualization-friendly view of : group headers
+ /// ( ) interleaved with visible rows, in
+ /// display order. Reconciled granularly by — never Clear+Add.
+ public ObservableCollection Rows { get; } = new();
+
+ private readonly HeaderRow _overdueHeaderRow = new() { IsOverdue = true };
+ private readonly HeaderRow _openHeaderRow = new();
+ private readonly HeaderRow _completedHeaderRow;
[ObservableProperty] private string _newTaskTitle = "";
[ObservableProperty] private TaskRowViewModel? _selectedTask;
@@ -118,6 +137,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{
_dbFactory = dbFactory;
_worker = worker;
+ _completedHeaderRow = new() { ActionCommand = ClearCompletedCommand };
CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
if (_worker is not null)
{
@@ -145,7 +165,6 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{
CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
foreach (var row in Items) row.RefreshLocalized();
- foreach (var row in CompletedItems) row.RefreshLocalized();
}
private async void OnWorkerListUpdated(string listId)
@@ -316,9 +335,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
_currentList.PropertyChanged += OnCurrentListPropertyChanged;
Items.Clear();
- OverdueItems.Clear();
- OpenItems.Clear();
- CompletedItems.Clear();
+ Rows.Clear();
HasOverdue = false;
HasOpen = false;
HasCompleted = false;
@@ -466,7 +483,54 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
r.IsExpanded = saved;
}
- // Build hierarchy-aware flat list: top-level rows interleaved with visible children.
+ var (overdue, open, completed) = ClassifyItems();
+
+ HasOverdue = overdue.Count > 0;
+ HasOpen = open.Count > 0;
+ HasCompleted = completed.Count > 0;
+ ShowOpenLabel = HasOpen && HasOverdue;
+ CompletedHeader = Loc.T("vm.tasksIsland.completedHeaderCount", completed.Count);
+
+ // Build the flat Rows target: header + rows per section, omitting a section entirely
+ // when it has nothing to show (no header, no rows) instead of hiding it via IsVisible.
+ var target = new List();
+ if (HasOverdue)
+ {
+ _overdueHeaderRow.Label = Loc.T("tasks.overdue");
+ _overdueHeaderRow.Count = overdue.Count;
+ target.Add(_overdueHeaderRow);
+ target.AddRange(overdue);
+ }
+ if (HasOpen)
+ {
+ if (ShowOpenLabel)
+ {
+ _openHeaderRow.Label = Loc.T("tasks.tasks");
+ _openHeaderRow.Count = open.Count;
+ target.Add(_openHeaderRow);
+ }
+ target.AddRange(open);
+ }
+ if (HasCompleted && IsShowingCompleted)
+ {
+ _completedHeaderRow.Label = CompletedHeader;
+ _completedHeaderRow.Count = completed.Count;
+ target.Add(_completedHeaderRow);
+ target.AddRange(completed);
+ }
+
+ // Reconcile Rows in place (granular Insert/Move/Remove) rather than Clear+Add, so toggling
+ // a parent only touches its own child rows — the ListBox keeps every unchanged container
+ // instead of tearing the whole list down on a Reset.
+ SyncCollection(Rows, target);
+ }
+
+ // Builds the hierarchy-aware flat ordering (top-level rows interleaved with visible children,
+ // orphans flagged so they render flat) and classifies it into Overdue/Open/Completed, exactly
+ // as Regroup renders it. Shared with callers that need "every completed row" independent of
+ // Rows/IsShowingCompleted (e.g. ClearCompletedAsync).
+ private (List Overdue, List Open, List Completed) ClassifyItems()
+ {
// Items is already ordered by SortOrder from the DB query.
// Treat rows whose ParentTaskId is not in the current view as orphans -> top-level.
var visibleIds = Items.Select(r => r.Id).ToHashSet();
@@ -486,7 +550,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
if (!emitted.Add(parent.Id)) continue;
flat.Add(parent);
// Also expand for Done parents so their (Done) children reach the classification
- // loop and land in CompletedItems alongside the parent.
+ // loop and land alongside the parent in Completed.
if ((parent.IsPlanningParent || parent.Done) && parent.IsExpanded)
{
var children = Items.Where(r => r.ParentTaskId == parent.Id);
@@ -513,18 +577,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
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;
- ShowOpenLabel = HasOpen && HasOverdue;
- CompletedHeader = Loc.T("vm.tasksIsland.completedHeaderCount", CompletedItems.Count);
+ return (overdue, open, completed);
}
private void UpdateSubtitle()
@@ -622,13 +675,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// Master Items: single Move event (no Reset) so ItemsControls animate, not rebuild.
MoveWithinCollection(Items, source, target, placeBelow);
- // Apply the same move in whichever section the row lives in.
+ // Apply the same move in Rows, but only when both rows live in the same section.
// Reorder never changes which section (Open/Overdue/Completed) a row belongs to —
// that's determined by Done flag and ScheduledFor date, not drag-drop.
var sourceSection = SectionFor(source);
var targetSection = SectionFor(target);
if (sourceSection is not null && ReferenceEquals(sourceSection, targetSection))
- MoveWithinCollection(sourceSection, source, target, placeBelow);
+ MoveWithinCollection(Rows, source, target, placeBelow);
var listId = _currentList.Id["user:".Length..];
var orderedIds = Items.Select(i => i.Id).ToList();
@@ -646,10 +699,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
await db.SaveChangesAsync();
}
- private static void MoveWithinCollection(
- System.Collections.ObjectModel.ObservableCollection coll,
- TaskRowViewModel source,
- TaskRowViewModel target,
+ private static void MoveWithinCollection(
+ System.Collections.ObjectModel.ObservableCollection coll,
+ T source,
+ T target,
bool placeBelow)
{
var srcIdx = coll.IndexOf(source);
@@ -667,11 +720,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// 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)
+ private static void SyncCollection(
+ System.Collections.ObjectModel.ObservableCollection dst,
+ List target)
{
- var keep = new HashSet(target);
+ var keep = new HashSet(target);
for (int i = dst.Count - 1; i >= 0; i--)
if (!keep.Contains(dst[i]))
dst.RemoveAt(i);
@@ -687,12 +740,21 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
}
}
- private System.Collections.ObjectModel.ObservableCollection? SectionFor(TaskRowViewModel row)
+ // Marker returned for a row that sits in the flat Open section before any header — Open is the
+ // only section that can render without a preceding HeaderRow (no Overdue and ShowOpenLabel
+ // false). Distinct from null, which means "row not found in Rows at all".
+ private static readonly object UnlabeledSection = new();
+
+ // A row's section is read off the nearest preceding HeaderRow in Rows rather than
+ // re-classified from Done/ScheduledFor, so it always matches what Regroup actually rendered
+ // (including planning-parent edge cases the classification alone can't reconstruct).
+ private object? SectionFor(TaskRowViewModel row)
{
- if (OverdueItems.Contains(row)) return OverdueItems;
- if (OpenItems.Contains(row)) return OpenItems;
- if (CompletedItems.Contains(row)) return CompletedItems;
- return null;
+ var idx = Rows.IndexOf(row);
+ if (idx < 0) return null;
+ for (int i = idx - 1; i >= 0; i--)
+ if (Rows[i] is HeaderRow header) return header;
+ return UnlabeledSection;
}
///
@@ -785,11 +847,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
[RelayCommand]
private async Task ClearCompletedAsync()
{
- if (CompletedItems.Count == 0) return;
+ var (_, _, completed) = ClassifyItems();
+ if (completed.Count == 0) return;
// Delete children before parents so the parent-child FK (Restrict) doesn't
// block removing a completed planning parent together with its done children.
- var toDelete = CompletedItems.OrderByDescending(r => r.IsChild).ToList();
+ var toDelete = completed.OrderByDescending(r => r.IsChild).ToList();
if (ConfirmAsync is not null)
{
@@ -1065,6 +1128,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
[RelayCommand]
private void ToggleShowCompleted() => IsShowingCompleted = !IsShowingCompleted;
+ // The completed section is only emitted into Rows while the toggle is on — flipping it
+ // doesn't change grouping/order data, so a full reload isn't needed, just a re-emit.
+ partial void OnIsShowingCompletedChanged(bool value) => Regroup();
+
public event EventHandler? OpenListSettingsRequested;
[RelayCommand]
diff --git a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml
index c5b5fff1..30261c92 100644
--- a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml
+++ b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml
@@ -2,7 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
- xmlns:converters="using:Avalonia.Data.Converters"
+ xmlns:uiconverters="using:ClaudeDo.Ui.Converters"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Islands.TasksIslandView"
x:DataType="vm:TasksIslandViewModel">
@@ -89,82 +89,64 @@
Command="{Binding OpenNotesCommand}"
Content="{loc:Tr tasks.notesPinnedRow}"/>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs
index f9e43757..85764034 100644
--- a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs
+++ b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs
@@ -1,5 +1,4 @@
using System;
-using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
@@ -62,19 +61,14 @@ public partial class TasksIslandView : UserControl
};
}
- // Bring the selected row into view — the task list is a plain ItemsControl with no
- // built-in selection scrolling, so a programmatic select (e.g. Mission Control's
- // "Open in app") would otherwise highlight a row that stays off-screen.
+ // Bring the selected row into view — a programmatic select (e.g. Mission Control's "Open in
+ // app") can target a row that isn't currently realized. RowsListBox.ScrollIntoView is
+ // virtualization-aware (it realizes the container as needed); a visual-tree search for an
+ // existing Button, as this used to do, only finds rows already on screen.
private void ScrollSelectedIntoView()
{
if (DataContext is not TasksIslandViewModel vm || vm.SelectedTask is not { } target) return;
- Dispatcher.UIThread.Post(() =>
- {
- var match = this.GetVisualDescendants()
- .OfType()
- .FirstOrDefault(b => ReferenceEquals(b.DataContext, target));
- match?.BringIntoView();
- }, DispatcherPriority.Background);
+ Dispatcher.UIThread.Post(() => RowsListBox.ScrollIntoView(target), DispatcherPriority.Background);
}
private async System.Threading.Tasks.Task ShowConfirmAsync(string message)
@@ -363,13 +357,12 @@ public partial class TasksIslandView : UserControl
return null;
}
+ // The next row in Rows is only "in the same section" while it's a TaskRowViewModel —
+ // hitting a HeaderRow (or the end of the list) means row was the last one in its section.
private static TaskRowViewModel? FindNextInSameSection(TasksIslandViewModel vm, TaskRowViewModel row)
{
- foreach (var section in new[] { vm.OverdueItems, vm.OpenItems, vm.CompletedItems })
- {
- var idx = section.IndexOf(row);
- if (idx >= 0) return idx + 1 < section.Count ? section[idx + 1] : null;
- }
- return null;
+ var idx = vm.Rows.IndexOf(row);
+ if (idx < 0 || idx + 1 >= vm.Rows.Count) return null;
+ return vm.Rows[idx + 1] as TaskRowViewModel;
}
}
diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRegroupTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRegroupTests.cs
index 3598dfc7..db6e8032 100644
--- a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRegroupTests.cs
+++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRegroupTests.cs
@@ -1,3 +1,4 @@
+using System.Collections.Specialized;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Islands;
@@ -84,6 +85,49 @@ public class TasksIslandRegroupTests : IDisposable
await db.SaveChangesAsync();
}
+ // Rows is the flat HeaderRow|TaskRowViewModel union — a row's section is read off the
+ // nearest preceding HeaderRow, mirroring TasksIslandViewModel.SectionFor. HasAction is
+ // unique to the Completed header (its Clear-completed button), so it disambiguates
+ // Completed from the (also non-overdue) Open header without depending on locale text.
+ private enum Section { None, Overdue, Open, Completed }
+
+ private static Section SectionOf(TasksIslandViewModel vm, TaskRowViewModel row)
+ {
+ var idx = vm.Rows.IndexOf(row);
+ if (idx < 0) return Section.None;
+ for (int i = idx - 1; i >= 0; i--)
+ if (vm.Rows[i] is HeaderRow h)
+ return h.IsOverdue ? Section.Overdue : h.HasAction ? Section.Completed : Section.Open;
+ return Section.Open;
+ }
+
+ private static bool IsInCompleted(TasksIslandViewModel vm, string taskId) =>
+ vm.Rows.OfType().Any(r => r.Id == taskId && SectionOf(vm, r) == Section.Completed);
+
+ private static bool IsInOpen(TasksIslandViewModel vm, string taskId, bool requireChild = false) =>
+ vm.Rows.OfType().Any(r =>
+ r.Id == taskId && (!requireChild || r.IsChild) && SectionOf(vm, r) == Section.Open);
+
+ private async Task SeedTasksAsync(params (string Id, TaskStatus Status, DateTime? ScheduledFor, int SortOrder)[] tasks)
+ {
+ await using var db = NewContext();
+ db.Lists.Add(new ListEntity { Id = "list1", Name = "Default", CreatedAt = DateTime.UtcNow });
+ foreach (var t in tasks)
+ {
+ db.Tasks.Add(new TaskEntity
+ {
+ Id = t.Id,
+ ListId = "list1",
+ Title = t.Id,
+ CreatedAt = DateTime.UtcNow,
+ Status = t.Status,
+ ScheduledFor = t.ScheduledFor,
+ SortOrder = t.SortOrder,
+ });
+ }
+ await db.SaveChangesAsync();
+ }
+
private static ListNavItemViewModel VirtualList(string id, string name) =>
new() { Id = id, Kind = ListKind.Virtual, Name = name };
@@ -171,10 +215,10 @@ public class TasksIslandRegroupTests : IDisposable
// 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
- Assert.Contains(vm.OpenItems, r => r.Id == "c1" && r.IsChild);
+ // Child with Done status under an open Planning parent should NOT land in the Completed section
+ Assert.False(IsInCompleted(vm, "c1"));
+ // Child should appear nested (IsChild == true) in the Open section
+ Assert.True(IsInOpen(vm, "c1", requireChild: true));
}
[Fact]
@@ -189,7 +233,120 @@ public class TasksIslandRegroupTests : IDisposable
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
- Assert.Contains(vm.CompletedItems, r => r.Id == "p1");
- Assert.Contains(vm.CompletedItems, r => r.Id == "c1");
+ Assert.True(IsInCompleted(vm, "p1"));
+ Assert.True(IsInCompleted(vm, "c1"));
+ }
+
+ [Fact]
+ public async Task Regroup_Rows_HeaderPositionsAndCountsMatchSections()
+ {
+ var yesterday = DateTime.Today.AddDays(-1);
+ await SeedTasksAsync(
+ ("overdue1", TaskStatus.Idle, yesterday, 0),
+ ("open1", TaskStatus.Idle, null, 1),
+ ("completed1", TaskStatus.Done, null, 2));
+
+ var vm = BuildViewModel();
+ await LoadAndWaitAsync(vm, UserList("list1", "Default"));
+
+ Assert.Equal(6, vm.Rows.Count);
+
+ var overdueHeader = Assert.IsType(vm.Rows[0]);
+ Assert.True(overdueHeader.IsOverdue);
+ Assert.Equal(1, overdueHeader.Count);
+ Assert.Equal("overdue1", Assert.IsType(vm.Rows[1]).Id);
+
+ // ShowOpenLabel is true here because Overdue is also present — the Open header IS emitted.
+ var openHeader = Assert.IsType(vm.Rows[2]);
+ Assert.False(openHeader.IsOverdue);
+ Assert.False(openHeader.HasAction);
+ Assert.Equal(1, openHeader.Count);
+ Assert.Equal("open1", Assert.IsType(vm.Rows[3]).Id);
+
+ var completedHeader = Assert.IsType(vm.Rows[4]);
+ Assert.True(completedHeader.HasAction);
+ Assert.Equal(1, completedHeader.Count);
+ Assert.Equal("completed1", Assert.IsType(vm.Rows[5]).Id);
+ }
+
+ [Fact]
+ public async Task Regroup_EmptySections_EmitNoHeader()
+ {
+ // Open-only: no Overdue means ShowOpenLabel is false too, so the Open header is never
+ // emitted either — Rows should hold exactly the one task row, nothing else.
+ await SeedTasksAsync(("open1", TaskStatus.Idle, null, 0));
+
+ var vm = BuildViewModel();
+ await LoadAndWaitAsync(vm, UserList("list1", "Default"));
+
+ Assert.False(vm.HasOverdue);
+ Assert.False(vm.HasCompleted);
+ Assert.False(vm.ShowOpenLabel);
+ var row = Assert.Single(vm.Rows);
+ Assert.Equal("open1", Assert.IsType(row).Id);
+ }
+
+ [Fact]
+ public async Task Regroup_CompletedSection_HiddenWhileIsShowingCompletedFalse()
+ {
+ await SeedTasksAsync(
+ ("open1", TaskStatus.Idle, null, 0),
+ ("completed1", TaskStatus.Done, null, 1));
+
+ var vm = BuildViewModel();
+ await LoadAndWaitAsync(vm, UserList("list1", "Default"));
+ Assert.True(IsInCompleted(vm, "completed1"));
+
+ vm.IsShowingCompleted = false;
+
+ Assert.DoesNotContain(vm.Rows.OfType(), h => h.HasAction);
+ Assert.DoesNotContain(vm.Rows.OfType(), r => r.Id == "completed1");
+ // HasCompleted reflects the underlying data, independent of the toggle.
+ Assert.True(vm.HasCompleted);
+
+ vm.IsShowingCompleted = true;
+ Assert.True(IsInCompleted(vm, "completed1"));
+ }
+
+ [Fact]
+ public async Task Rows_ParentExpandCollapse_ReconcilesGranularly_NoResetEvent()
+ {
+ await SeedPlanningWithChildAsync(
+ parentStatus: TaskStatus.Idle, parentPhase: PlanningPhase.Active,
+ childStatus: TaskStatus.Idle,
+ parentId: "p1",
+ childId: "c1");
+
+ var vm = BuildViewModel();
+ await LoadAndWaitAsync(vm, UserList("list1", "Default"));
+
+ var actions = new List();
+ vm.Rows.CollectionChanged += (_, e) => actions.Add(e.Action);
+
+ vm.ToggleExpandCommand.Execute(vm.Items.First(r => r.Id == "p1"));
+ vm.ToggleExpandCommand.Execute(vm.Items.First(r => r.Id == "p1"));
+
+ Assert.NotEmpty(actions);
+ Assert.DoesNotContain(NotifyCollectionChangedAction.Reset, actions);
+ }
+
+ [Fact]
+ public async Task ReorderAsync_WithinOpenSection_MovesRowInItemsAndRows()
+ {
+ await SeedTasksAsync(
+ ("t1", TaskStatus.Idle, null, 0),
+ ("t2", TaskStatus.Idle, null, 1),
+ ("t3", TaskStatus.Idle, null, 2));
+
+ var vm = BuildViewModel();
+ await LoadAndWaitAsync(vm, UserList("list1", "Default"));
+
+ var t1 = vm.Items.First(r => r.Id == "t1");
+ var t3 = vm.Items.First(r => r.Id == "t3");
+
+ await vm.ReorderAsync(t1, t3, placeBelow: true);
+
+ Assert.Equal(new[] { "t2", "t3", "t1" }, vm.Items.Select(r => r.Id));
+ Assert.Equal(new[] { "t2", "t3", "t1" }, vm.Rows.OfType().Select(r => r.Id));
}
}
diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
index 2f83dd9a..e3a9c9c5 100644
--- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
+++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
@@ -211,6 +211,19 @@ public class TasksIslandViewModelPlanningTests
PlanningPhase phase = PlanningPhase.None)
=> new TaskRowViewModel { Id = id, Status = status, ParentTaskId = parentId, PlanningPhase = phase };
+ // vm.Rows is the flat HeaderRow|TaskRowViewModel union — a row is "in the Open section" when
+ // it's present in Rows and the nearest preceding HeaderRow (if any) isn't Overdue/Completed.
+ // HasAction uniquely identifies the Completed header (its Clear-completed button).
+ private static bool IsVisibleInOpen(TasksIslandViewModel vm, TaskRowViewModel row)
+ {
+ var idx = vm.Rows.IndexOf(row);
+ if (idx < 0) return false;
+ for (int i = idx - 1; i >= 0; i--)
+ if (vm.Rows[i] is HeaderRow h)
+ return !h.IsOverdue && !h.HasAction;
+ return true;
+ }
+
[Fact]
public void PlanningParentWithChildren_CollapsedByDefault_ToggleExpands()
{
@@ -221,16 +234,16 @@ public class TasksIslandViewModelPlanningTests
var (vm, _) = VmFactory.Create([parent, child1, child2]);
// Collapsed by default — children hidden, parent still present
- Assert.DoesNotContain(child1, vm.OpenItems);
- Assert.DoesNotContain(child2, vm.OpenItems);
- Assert.Contains(parent, vm.OpenItems);
+ Assert.False(IsVisibleInOpen(vm, child1));
+ Assert.False(IsVisibleInOpen(vm, child2));
+ Assert.True(IsVisibleInOpen(vm, parent));
// Expand the parent
vm.ToggleExpandCommand.Execute(parent);
// Children now visible
- Assert.Contains(child1, vm.OpenItems);
- Assert.Contains(child2, vm.OpenItems);
+ Assert.True(IsVisibleInOpen(vm, child1));
+ Assert.True(IsVisibleInOpen(vm, child2));
}
[Fact]
@@ -268,15 +281,15 @@ public class TasksIslandViewModelPlanningTests
var (vm, _) = VmFactory.Create([parent, child]);
// Collapsed by default
- Assert.DoesNotContain(child, vm.OpenItems);
+ Assert.False(IsVisibleInOpen(vm, child));
// Expand
vm.ToggleExpandCommand.Execute(parent);
- Assert.Contains(child, vm.OpenItems);
+ Assert.True(IsVisibleInOpen(vm, child));
// Collapse again
vm.ToggleExpandCommand.Execute(parent);
- Assert.DoesNotContain(child, vm.OpenItems);
+ Assert.False(IsVisibleInOpen(vm, child));
}
[Fact]
@@ -291,7 +304,7 @@ public class TasksIslandViewModelPlanningTests
Assert.False(orphan.ParentInView);
Assert.False(orphan.ShowAsChild);
Assert.False(orphan.IsDraft);
- Assert.Contains(orphan, vm.OpenItems);
+ Assert.True(IsVisibleInOpen(vm, orphan));
}
[Fact]