From bdb709b264d04d16728d7c524a6886398a03afbb Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Mon, 27 Apr 2026 10:16:40 +0200 Subject: [PATCH] feat(ui): show dequeue affordance on planning parents with queued children Planning parents stay in Planning/Planned status while their children are Queued/Waiting, so the existing IsQueued-only visibility rule hid the dequeue button. Add HasQueuedSubtasks tracking and a CanRemoveFromQueue helper; the parent-row dequeue cascades to all queued/waiting children. Also attach the 'agent' tag on explicit enqueue so the queue picker accepts the task. --- .../ViewModels/Islands/TaskRowViewModel.cs | 6 +++ .../Islands/TasksIslandViewModel.cs | 54 +++++++++++++++++-- .../Views/Islands/TaskRowView.axaml | 6 +-- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index 9b7e576..6090e92 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -26,6 +26,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase [ObservableProperty] private string? _parentTaskId; [ObservableProperty] private bool _isExpanded = true; [ObservableProperty] private bool _hasPlanningChildren; + [ObservableProperty] private bool _hasQueuedSubtasks; public DateTime CreatedAt { get; init; } public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}"; @@ -58,6 +59,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase public bool IsRunning => Status == TaskStatus.Running; public bool IsQueued => Status == TaskStatus.Queued; public bool IsWaiting => Status == TaskStatus.Waiting; + public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks; public bool HasSchedule => ScheduledFor.HasValue; public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail); @@ -87,8 +89,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase OnPropertyChanged(nameof(IsDraft)); OnPropertyChanged(nameof(CanOpenPlanningSession)); OnPropertyChanged(nameof(CanResumeOrDiscardPlanning)); + OnPropertyChanged(nameof(CanRemoveFromQueue)); } + partial void OnHasQueuedSubtasksChanged(bool value) + => OnPropertyChanged(nameof(CanRemoveFromQueue)); + partial void OnParentTaskIdChanged(string? value) { OnPropertyChanged(nameof(IsChild)); diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 4547958..bd26485 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -100,6 +100,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase else return; } + // Keep the parent's HasQueuedSubtasks flag in sync when a child's status flips. + if (entity is not null && !string.IsNullOrEmpty(entity.ParentTaskId)) + { + var parent = Items.FirstOrDefault(r => r.Id == entity.ParentTaskId); + if (parent is not null) + parent.HasQueuedSubtasks = Items.Any(r => + r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting)); + } + Regroup(); UpdateSubtitle(); } @@ -207,6 +216,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase if (parentsWithChildren.Contains(r.Id)) r.HasPlanningChildren = true; + // Mark planning parents whose children are currently queued/waiting, + // so the dequeue affordance is visible on the parent row. + var parentsWithQueuedKids = Items + .Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId) + && (r.IsQueued || r.IsWaiting)) + .Select(r => r.ParentTaskId!) + .ToHashSet(); + foreach (var r in Items) + r.HasQueuedSubtasks = parentsWithQueuedKids.Contains(r.Id); + Regroup(); UpdateSubtitle(); } @@ -446,9 +465,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase { if (row is null || row.IsRunning) return; await using var db = await _dbFactory.CreateDbContextAsync(); - var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id); + var entity = await db.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == row.Id); if (entity is null) return; entity.Status = TaskStatus.Queued; + // Worker queue picker requires the "agent" tag — attach it on explicit enqueue. + if (!entity.Tags.Any(t => t.Name == "agent")) + { + var agentTag = await db.Tags.FirstOrDefaultAsync(t => t.Name == "agent"); + if (agentTag is not null) entity.Tags.Add(agentTag); + } await db.SaveChangesAsync(); row.Status = TaskStatus.Queued; if (_worker is not null) @@ -467,9 +492,30 @@ public sealed partial class TasksIslandViewModel : ViewModelBase await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id); if (entity is null) return; - entity.Status = TaskStatus.Manual; - await db.SaveChangesAsync(); - row.Status = TaskStatus.Manual; + + // For a planning parent the dequeue button targets queued/waiting children, + // not the parent itself (whose Status is Planning/Planned). + if (entity.Status == TaskStatus.Planning || entity.Status == TaskStatus.Planned) + { + var children = await db.Tasks + .Where(t => t.ParentTaskId == row.Id + && (t.Status == TaskStatus.Queued || t.Status == TaskStatus.Waiting)) + .ToListAsync(); + foreach (var c in children) c.Status = TaskStatus.Manual; + 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.Manual; + } + row.HasQueuedSubtasks = false; + } + else + { + entity.Status = TaskStatus.Manual; + await db.SaveChangesAsync(); + row.Status = TaskStatus.Manual; + } Regroup(); UpdateSubtitle(); TasksChanged?.Invoke(this, EventArgs.Empty); diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml index 14cc299..73d2545 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml @@ -35,7 +35,7 @@ IsVisible="{Binding !IsQueued}" Click="OnSendToQueueClick"/> - +