From 4c92da55ad5cc35ed3e20c9cd8d6dec971c16446 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 30 Apr 2026 14:17:37 +0200 Subject: [PATCH] feat(ui): cascade dequeue to queued children for any parent RemoveFromQueue previously gated cascade on PlanningPhase != None, leaving manually-built chains stuck if their parent had no planning phase. The handler now matches the X button's HasQueuedSubtasks gate: queued children are unqueued and unblocked regardless of the parent's planning phase. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Islands/TasksIslandViewModel.cs | 51 +++--- .../TasksIslandRemoveFromQueueTests.cs | 166 ++++++++++++++++++ 2 files changed, 191 insertions(+), 26 deletions(-) create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRemoveFromQueueTests.cs diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 204b277..8cf6c91 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -535,36 +535,35 @@ public sealed partial class TasksIslandViewModel : ViewModelBase var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id); if (entity is null) return; - // For a planning parent the dequeue button targets queued children - // (chain-blocked or not), not the parent itself. - if (entity.PlanningPhase != PlanningPhase.None) + // Cascade to queued children when present — covers both planning parents + // (PlanningPhase != None) and bare parents that have a manually-queued + // chain. The X button's visibility is gated by the same condition + // (HasQueuedSubtasks), so the handler matches what the user can see. + var queuedChildren = await db.Tasks + .Where(t => t.ParentTaskId == row.Id && t.Status == TaskStatus.Queued) + .ToListAsync(); + foreach (var c in queuedChildren) { - var children = await db.Tasks - .Where(t => t.ParentTaskId == row.Id && t.Status == TaskStatus.Queued) - .ToListAsync(); - foreach (var c in children) - { - c.Status = TaskStatus.Idle; - c.BlockedByTaskId = null; - } - await db.SaveChangesAsync(); - foreach (var c in children) - { - var childRow = Items.FirstOrDefault(r => r.Id == c.Id); - if (childRow is not null) - { - childRow.Status = TaskStatus.Idle; - childRow.BlockedByTaskId = null; - } - } - row.HasQueuedSubtasks = false; + c.Status = TaskStatus.Idle; + c.BlockedByTaskId = null; } - else - { + if (entity.Status == TaskStatus.Queued) entity.Status = TaskStatus.Idle; - await db.SaveChangesAsync(); - row.Status = TaskStatus.Idle; + await db.SaveChangesAsync(); + + foreach (var c in queuedChildren) + { + var childRow = Items.FirstOrDefault(r => r.Id == c.Id); + if (childRow is not null) + { + childRow.Status = TaskStatus.Idle; + childRow.BlockedByTaskId = null; + } } + if (row.Status == TaskStatus.Queued) + row.Status = TaskStatus.Idle; + row.HasQueuedSubtasks = false; + Regroup(); UpdateSubtitle(); TasksChanged?.Invoke(this, EventArgs.Empty); diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRemoveFromQueueTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRemoveFromQueueTests.cs new file mode 100644 index 0000000..031e4c2 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRemoveFromQueueTests.cs @@ -0,0 +1,166 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.ViewModels.Islands; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public class TasksIslandRemoveFromQueueTests : IDisposable +{ + private readonly string _dbPath; + + public TasksIslandRemoveFromQueueTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_unq_{Guid.NewGuid():N}.db"); + using var ctx = NewContext(); + ctx.Database.EnsureCreated(); + } + + public void Dispose() + { + try { File.Delete(_dbPath); } catch { } + try { File.Delete(_dbPath + "-wal"); } catch { } + try { File.Delete(_dbPath + "-shm"); } catch { } + } + + private ClaudeDoDbContext NewContext() + { + var opts = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + return new ClaudeDoDbContext(opts); + } + + private sealed class TestDbFactory : IDbContextFactory + { + private readonly Func _create; + public TestDbFactory(Func create) => _create = create; + public ClaudeDoDbContext CreateDbContext() => _create(); + } + + private TasksIslandViewModel BuildViewModel() => + new(new TestDbFactory(NewContext), worker: null); + + private async Task SeedParentWithChainAsync( + string parentId, + PlanningPhase parentPhase, + params (string Id, TaskStatus Status, string? BlockedBy)[] children) + { + await using var db = NewContext(); + db.Lists.Add(new ListEntity + { + Id = "list1", + Name = "Default", + CreatedAt = DateTime.UtcNow, + }); + db.Tasks.Add(new TaskEntity + { + Id = parentId, + ListId = "list1", + Title = "Parent", + CreatedAt = DateTime.UtcNow, + Status = TaskStatus.Idle, + PlanningPhase = parentPhase, + SortOrder = 0, + }); + for (int i = 0; i < children.Length; i++) + { + var c = children[i]; + db.Tasks.Add(new TaskEntity + { + Id = c.Id, + ListId = "list1", + Title = $"Child {i}", + CreatedAt = DateTime.UtcNow, + Status = c.Status, + ParentTaskId = parentId, + BlockedByTaskId = c.BlockedBy, + SortOrder = i + 1, + }); + } + await db.SaveChangesAsync(); + } + + private static async Task LoadAndWaitAsync(TasksIslandViewModel vm) + { + var list = new ListNavItemViewModel { Id = "user:list1", Kind = ListKind.User, Name = "Default" }; + vm.LoadForList(list); + var deadline = DateTime.UtcNow.AddSeconds(5); + while (DateTime.UtcNow < deadline) + { + await Task.Delay(25); + if (vm.Items.Count > 0) break; + } + await Task.Delay(50); + } + + [Fact] + public async Task RemoveFromQueue_PlanningPhaseNone_ParentWithQueuedChildren_CascadesUnqueue() + { + // Mirrors the BoxDataReader scenario: parent has PlanningPhase=None + // but a queued chain of children exists under it. Click X on the parent + // should clear the chain, even though planning_phase is None. + await SeedParentWithChainAsync( + "p1", + PlanningPhase.None, + ("c1", TaskStatus.Idle, null), + ("c2", TaskStatus.Queued, null), + ("c3", TaskStatus.Queued, "c2"), + ("c4", TaskStatus.Queued, "c3")); + + var vm = BuildViewModel(); + await LoadAndWaitAsync(vm); + + var parentRow = vm.Items.First(r => r.Id == "p1"); + await vm.RemoveFromQueueCommand.ExecuteAsync(parentRow); + + await using var db = NewContext(); + var kids = await db.Tasks.AsNoTracking() + .Where(t => t.ParentTaskId == "p1") + .OrderBy(t => t.SortOrder) + .ToListAsync(); + + Assert.Equal(TaskStatus.Idle, kids[0].Status); + Assert.All(kids.Where(k => k.Id != "c1"), k => + { + Assert.Equal(TaskStatus.Idle, k.Status); + Assert.Null(k.BlockedByTaskId); + }); + } + + [Fact] + public async Task RemoveFromQueue_QueuedTaskWithoutChildren_UnqueuesItself() + { + // Sanity check: existing single-task unqueue path still works. + await using (var db = NewContext()) + { + db.Lists.Add(new ListEntity + { + Id = "list1", + Name = "Default", + CreatedAt = DateTime.UtcNow, + }); + db.Tasks.Add(new TaskEntity + { + Id = "solo", + ListId = "list1", + Title = "Solo", + CreatedAt = DateTime.UtcNow, + Status = TaskStatus.Queued, + SortOrder = 0, + }); + await db.SaveChangesAsync(); + } + + var vm = BuildViewModel(); + await LoadAndWaitAsync(vm); + + var row = vm.Items.First(r => r.Id == "solo"); + await vm.RemoveFromQueueCommand.ExecuteAsync(row); + + await using var verify = NewContext(); + var t = await verify.Tasks.AsNoTracking().FirstAsync(x => x.Id == "solo"); + Assert.Equal(TaskStatus.Idle, t.Status); + } +}