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.
This commit is contained in:
Mika Kuns
2026-04-27 10:16:40 +02:00
parent 2d7f825ff3
commit bdb709b264
3 changed files with 59 additions and 7 deletions

View File

@@ -26,6 +26,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
[ObservableProperty] private string? _parentTaskId; [ObservableProperty] private string? _parentTaskId;
[ObservableProperty] private bool _isExpanded = true; [ObservableProperty] private bool _isExpanded = true;
[ObservableProperty] private bool _hasPlanningChildren; [ObservableProperty] private bool _hasPlanningChildren;
[ObservableProperty] private bool _hasQueuedSubtasks;
public DateTime CreatedAt { get; init; } public DateTime CreatedAt { get; init; }
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}"; 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 IsRunning => Status == TaskStatus.Running;
public bool IsQueued => Status == TaskStatus.Queued; public bool IsQueued => Status == TaskStatus.Queued;
public bool IsWaiting => Status == TaskStatus.Waiting; public bool IsWaiting => Status == TaskStatus.Waiting;
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
public bool HasSchedule => ScheduledFor.HasValue; public bool HasSchedule => ScheduledFor.HasValue;
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail); public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
@@ -87,8 +89,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(IsDraft)); OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(CanOpenPlanningSession)); OnPropertyChanged(nameof(CanOpenPlanningSession));
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning)); OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
OnPropertyChanged(nameof(CanRemoveFromQueue));
} }
partial void OnHasQueuedSubtasksChanged(bool value)
=> OnPropertyChanged(nameof(CanRemoveFromQueue));
partial void OnParentTaskIdChanged(string? value) partial void OnParentTaskIdChanged(string? value)
{ {
OnPropertyChanged(nameof(IsChild)); OnPropertyChanged(nameof(IsChild));

View File

@@ -100,6 +100,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
else return; 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(); Regroup();
UpdateSubtitle(); UpdateSubtitle();
} }
@@ -207,6 +216,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
if (parentsWithChildren.Contains(r.Id)) if (parentsWithChildren.Contains(r.Id))
r.HasPlanningChildren = true; 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(); Regroup();
UpdateSubtitle(); UpdateSubtitle();
} }
@@ -446,9 +465,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
{ {
if (row is null || row.IsRunning) return; if (row is null || row.IsRunning) return;
await using var db = await _dbFactory.CreateDbContextAsync(); 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; if (entity is null) return;
entity.Status = TaskStatus.Queued; 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(); await db.SaveChangesAsync();
row.Status = TaskStatus.Queued; row.Status = TaskStatus.Queued;
if (_worker is not null) if (_worker is not null)
@@ -467,9 +492,30 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
await using var db = await _dbFactory.CreateDbContextAsync(); await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id); var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity is null) return; if (entity is null) return;
// 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; entity.Status = TaskStatus.Manual;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
row.Status = TaskStatus.Manual; row.Status = TaskStatus.Manual;
}
Regroup(); Regroup();
UpdateSubtitle(); UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty); TasksChanged?.Invoke(this, EventArgs.Empty);

View File

@@ -35,7 +35,7 @@
IsVisible="{Binding !IsQueued}" IsVisible="{Binding !IsQueued}"
Click="OnSendToQueueClick"/> Click="OnSendToQueueClick"/>
<MenuItem Header="Remove from queue" <MenuItem Header="Remove from queue"
IsVisible="{Binding IsQueued}" IsVisible="{Binding CanRemoveFromQueue}"
Click="OnRemoveFromQueueClick"/> Click="OnRemoveFromQueueClick"/>
<Separator/> <Separator/>
<MenuItem Header="Run interactively" <MenuItem Header="Run interactively"
@@ -119,9 +119,9 @@
<TextBlock Text="{Binding Status}"/> <TextBlock Text="{Binding Status}"/>
</Border> </Border>
<!-- Dequeue button (only when Queued) --> <!-- Dequeue button (visible when row is Queued, or planning parent has queued subtasks) -->
<Button Classes="icon-btn dequeue-btn" <Button Classes="icon-btn dequeue-btn"
IsVisible="{Binding IsQueued}" IsVisible="{Binding CanRemoveFromQueue}"
ToolTip.Tip="Remove from queue" ToolTip.Tip="Remove from queue"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RemoveFromQueueCommand}" Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RemoveFromQueueCommand}"
CommandParameter="{Binding}"> CommandParameter="{Binding}">