feat(ui): queue planning subtasks sequentially and surface waiting status
Adds a "Queue subtasks sequentially" context-menu entry on rows with planning children, wires it to WorkerHub.QueuePlanningSubtasksAsync via IWorkerClient. TaskRowViewModel exposes IsWaiting/StatusChipClass for the new Waiting status, and HasPlanningChildren keeps parents expandable after they leave the planning state. TasksIslandViewModel auto-collapses parents whose every child is Done and includes Waiting children in the queued virtual list. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,4 +38,5 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
Task MergeAllPlanningAsync(string planningTaskId, string targetBranch);
|
Task MergeAllPlanningAsync(string planningTaskId, string targetBranch);
|
||||||
Task ContinuePlanningMergeAsync(string planningTaskId);
|
Task ContinuePlanningMergeAsync(string planningTaskId);
|
||||||
Task AbortPlanningMergeAsync(string planningTaskId);
|
Task AbortPlanningMergeAsync(string planningTaskId);
|
||||||
|
Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -436,6 +436,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
await _hub.InvokeAsync("AbortPlanningMerge", planningTaskId);
|
await _hub.InvokeAsync("AbortPlanningMerge", planningTaskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
// IWorkerClient explicit implementations (drop typed return values)
|
// IWorkerClient explicit implementations (drop typed return values)
|
||||||
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
|
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
|
||||||
=> await StartPlanningSessionAsync(taskId, ct);
|
=> await StartPlanningSessionAsync(taskId, ct);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private bool _dropHintBelow;
|
[ObservableProperty] private bool _dropHintBelow;
|
||||||
[ObservableProperty] private string? _parentTaskId;
|
[ObservableProperty] private string? _parentTaskId;
|
||||||
[ObservableProperty] private bool _isExpanded = true;
|
[ObservableProperty] private bool _isExpanded = true;
|
||||||
|
[ObservableProperty] private bool _hasPlanningChildren;
|
||||||
|
|
||||||
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}";
|
||||||
@@ -34,7 +35,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
public int StepsCompleted { get; init; }
|
public int StepsCompleted { get; init; }
|
||||||
|
|
||||||
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
|
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
|
||||||
public bool IsPlanningParent => Status == TaskStatus.Planning || Status == TaskStatus.Planned;
|
public bool IsPlanningParent => Status == TaskStatus.Planning
|
||||||
|
|| Status == TaskStatus.Planned
|
||||||
|
|| HasPlanningChildren;
|
||||||
public bool IsDraft => Status == TaskStatus.Draft;
|
public bool IsDraft => Status == TaskStatus.Draft;
|
||||||
|
|
||||||
public bool CanOpenPlanningSession => Status == TaskStatus.Manual && !IsChild;
|
public bool CanOpenPlanningSession => Status == TaskStatus.Manual && !IsChild;
|
||||||
@@ -54,6 +57,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
||||||
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 HasSchedule => ScheduledFor.HasValue;
|
public bool HasSchedule => ScheduledFor.HasValue;
|
||||||
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
|
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
|
||||||
|
|
||||||
@@ -67,6 +71,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
TaskStatus.Failed => "error",
|
TaskStatus.Failed => "error",
|
||||||
TaskStatus.Done => "review",
|
TaskStatus.Done => "review",
|
||||||
TaskStatus.Queued => "queued",
|
TaskStatus.Queued => "queued",
|
||||||
|
TaskStatus.Waiting => "waiting",
|
||||||
_ => "idle",
|
_ => "idle",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,6 +80,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(StatusChipClass));
|
OnPropertyChanged(nameof(StatusChipClass));
|
||||||
OnPropertyChanged(nameof(IsRunning));
|
OnPropertyChanged(nameof(IsRunning));
|
||||||
OnPropertyChanged(nameof(IsQueued));
|
OnPropertyChanged(nameof(IsQueued));
|
||||||
|
OnPropertyChanged(nameof(IsWaiting));
|
||||||
OnPropertyChanged(nameof(HasLiveTail));
|
OnPropertyChanged(nameof(HasLiveTail));
|
||||||
OnPropertyChanged(nameof(IsPlanningParent));
|
OnPropertyChanged(nameof(IsPlanningParent));
|
||||||
OnPropertyChanged(nameof(PlanningBadge));
|
OnPropertyChanged(nameof(PlanningBadge));
|
||||||
@@ -89,6 +95,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnHasPlanningChildrenChanged(bool value)
|
||||||
|
=> OnPropertyChanged(nameof(IsPlanningParent));
|
||||||
|
|
||||||
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
||||||
partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail));
|
partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail));
|
||||||
partial void OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue));
|
partial void OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue));
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null),
|
ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null),
|
||||||
ListKind.Virtual when list.Id == "virtual:queued" => all.Where(t =>
|
ListKind.Virtual when list.Id == "virtual:queued" => all.Where(t =>
|
||||||
(t.Status == TaskStatus.Queued && t.ParentTaskId == null) ||
|
(t.Status == TaskStatus.Queued && t.ParentTaskId == null) ||
|
||||||
(IsPlanningStatus(t.Status) && all.Any(c => c.ParentTaskId == t.Id && c.Status == TaskStatus.Queued))),
|
(IsPlanningStatus(t.Status) && all.Any(c => c.ParentTaskId == t.Id && (c.Status == TaskStatus.Queued || c.Status == TaskStatus.Waiting)))),
|
||||||
ListKind.Virtual when list.Id == "virtual:running" => all.Where(t =>
|
ListKind.Virtual when list.Id == "virtual:running" => all.Where(t =>
|
||||||
(t.Status == TaskStatus.Running && t.ParentTaskId == null) ||
|
(t.Status == TaskStatus.Running && t.ParentTaskId == null) ||
|
||||||
(IsPlanningStatus(t.Status) && all.Any(c => c.ParentTaskId == t.Id && c.Status == TaskStatus.Running))),
|
(IsPlanningStatus(t.Status) && all.Any(c => c.ParentTaskId == t.Id && c.Status == TaskStatus.Running))),
|
||||||
@@ -197,6 +197,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
foreach (var t in filteredList)
|
foreach (var t in filteredList)
|
||||||
Items.Add(TaskRowViewModel.FromEntity(t));
|
Items.Add(TaskRowViewModel.FromEntity(t));
|
||||||
|
|
||||||
|
// Mark any top-level row that has at least one child as a planning parent,
|
||||||
|
// so its subtasks remain expandable even after the parent is queued/running.
|
||||||
|
var parentsWithChildren = Items
|
||||||
|
.Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId))
|
||||||
|
.Select(r => r.ParentTaskId!)
|
||||||
|
.ToHashSet();
|
||||||
|
foreach (var r in Items)
|
||||||
|
if (parentsWithChildren.Contains(r.Id))
|
||||||
|
r.HasPlanningChildren = true;
|
||||||
|
|
||||||
Regroup();
|
Regroup();
|
||||||
UpdateSubtitle();
|
UpdateSubtitle();
|
||||||
}
|
}
|
||||||
@@ -209,6 +219,23 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
OpenItems.Clear();
|
OpenItems.Clear();
|
||||||
CompletedItems.Clear();
|
CompletedItems.Clear();
|
||||||
|
|
||||||
|
// Auto-collapse planning parents whose every child is Done (unless the user
|
||||||
|
// has explicitly toggled the row — saved state wins).
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
if (_expandedState.ContainsKey(parent.Id)) continue;
|
||||||
|
if (childrenByParent.TryGetValue(parent.Id, out var kids)
|
||||||
|
&& kids.Count > 0
|
||||||
|
&& kids.All(c => c.Status == TaskStatus.Done))
|
||||||
|
{
|
||||||
|
parent.IsExpanded = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Restore IsExpanded from saved state
|
// Restore IsExpanded from saved state
|
||||||
foreach (var r in Items)
|
foreach (var r in Items)
|
||||||
{
|
{
|
||||||
@@ -537,6 +564,14 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task QueuePlanningSubtasksAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || _worker is null) return;
|
||||||
|
try { await _worker.QueuePlanningSubtasksAsync(row.Id); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
|
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,6 +47,9 @@
|
|||||||
<MenuItem Header="Discard planning session"
|
<MenuItem Header="Discard planning session"
|
||||||
Click="OnDiscardPlanningSessionClick"
|
Click="OnDiscardPlanningSessionClick"
|
||||||
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
||||||
|
<MenuItem Header="Queue subtasks sequentially"
|
||||||
|
Click="OnQueuePlanningSubtasksClick"
|
||||||
|
IsVisible="{Binding HasPlanningChildren}"/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/>
|
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/>
|
||||||
<MenuItem Header="Clear schedule"
|
<MenuItem Header="Clear schedule"
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ public partial class TaskRowView : UserControl
|
|||||||
await vm.DiscardPlanningSessionCommand.ExecuteAsync(row);
|
await vm.DiscardPlanningSessionCommand.ExecuteAsync(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnQueuePlanningSubtasksClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
|
await vm.QueuePlanningSubtasksCommand.ExecuteAsync(row);
|
||||||
|
}
|
||||||
|
|
||||||
private void OnScheduleForClick(object? sender, RoutedEventArgs e)
|
private void OnScheduleForClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is not TaskRowViewModel row) return;
|
if (DataContext is not TaskRowViewModel row) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user