feat(ui): add queueing and scheduling from task row context menu
- Right-click on a task row exposes Send to queue / Remove from queue and Schedule for... / Clear schedule actions. - New virtual:queued list in the sidebar with live count. - Sidebar counts are now computed (open per list, running, queued, review) and refreshed on task- and worker-side events. - Sending a task to the queue wakes the worker so it starts immediately.
This commit is contained in:
@@ -2,6 +2,8 @@ using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
@@ -68,7 +70,12 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
: Environment.UserName.ToUpperInvariant();
|
||||
|
||||
if (_worker is not null)
|
||||
_worker.ListUpdatedEvent += id => _ = RefreshRowAsync(id);
|
||||
{
|
||||
_worker.ListUpdatedEvent += id => _ = RefreshRowAsync(id);
|
||||
_worker.TaskStartedEvent += (_slot, _id, _at) => _ = RefreshCountsAsync();
|
||||
_worker.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = RefreshCountsAsync();
|
||||
_worker.TaskUpdatedEvent += _id => _ = RefreshCountsAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LoadAsync(CancellationToken ct = default)
|
||||
@@ -82,6 +89,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
new ListNavItemViewModel { Id = "smart:my-day", Name = "My Day", Kind = ListKind.Smart, IconKey = "Sun" },
|
||||
new ListNavItemViewModel { Id = "smart:important", Name = "Important", Kind = ListKind.Smart, IconKey = "Star" },
|
||||
new ListNavItemViewModel { Id = "smart:planned", Name = "Planned", Kind = ListKind.Smart, IconKey = "Calendar" },
|
||||
new ListNavItemViewModel { Id = "virtual:queued", Name = "Queue", Kind = ListKind.Virtual, IconKey = "Inbox" },
|
||||
new ListNavItemViewModel { Id = "virtual:running", Name = "Running", Kind = ListKind.Virtual, IconKey = "Activity" },
|
||||
new ListNavItemViewModel { Id = "virtual:review", Name = "Review", Kind = ListKind.Virtual, IconKey = "Eye" },
|
||||
};
|
||||
@@ -116,8 +124,46 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
|
||||
public async Task RefreshCountsAsync(CancellationToken ct = default)
|
||||
{
|
||||
foreach (var i in Items) i.Count = 0;
|
||||
await Task.CompletedTask;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
// Snapshot the open (non-Done) tasks once; small enough collection for client-side grouping.
|
||||
var open = await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => t.Status != TaskStatus.Done)
|
||||
.Select(t => new { t.ListId, t.Status, t.IsMyDay, t.IsStarred, Scheduled = t.ScheduledFor })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var running = open.Count(t => t.Status == TaskStatus.Running);
|
||||
var queued = open.Count(t => t.Status == TaskStatus.Queued);
|
||||
var review = await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => t.Status == TaskStatus.Done && t.Worktree != null && t.Worktree.State == WorktreeState.Active)
|
||||
.CountAsync(ct);
|
||||
|
||||
foreach (var item in SmartLists)
|
||||
{
|
||||
item.Count = item.Id switch
|
||||
{
|
||||
"smart:my-day" => open.Count(t => t.IsMyDay),
|
||||
"smart:important" => open.Count(t => t.IsStarred),
|
||||
"smart:planned" => open.Count(t => t.Scheduled != null),
|
||||
"virtual:queued" => queued,
|
||||
"virtual:running" => running,
|
||||
"virtual:review" => review,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
foreach (var item in UserLists)
|
||||
{
|
||||
var listId = item.Id.StartsWith("user:", StringComparison.Ordinal)
|
||||
? item.Id["user:".Length..]
|
||||
: item.Id;
|
||||
item.Count = open.Count(t => t.ListId == listId);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch { /* best-effort refresh */ }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
||||
@@ -37,6 +37,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public bool HasSteps => StepsCount > 0;
|
||||
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
||||
public bool IsRunning => Status == TaskStatus.Running;
|
||||
public bool IsQueued => Status == TaskStatus.Queued;
|
||||
public bool HasSchedule => ScheduledFor.HasValue;
|
||||
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
|
||||
|
||||
public string DiffAdditionsText => $"+{DiffAdditions}";
|
||||
@@ -56,13 +58,18 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
{
|
||||
OnPropertyChanged(nameof(StatusChipClass));
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsQueued));
|
||||
OnPropertyChanged(nameof(HasLiveTail));
|
||||
}
|
||||
|
||||
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
||||
partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail));
|
||||
partial void OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue));
|
||||
partial void OnScheduledForChanged(DateTime? value) => OnPropertyChanged(nameof(IsOverdue));
|
||||
partial void OnScheduledForChanged(DateTime? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsOverdue));
|
||||
OnPropertyChanged(nameof(HasSchedule));
|
||||
}
|
||||
partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffAdditionsText)); }
|
||||
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); }
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
@@ -12,11 +13,13 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly WorkerClient? _worker;
|
||||
private ListNavItemViewModel? _currentList;
|
||||
private CancellationTokenSource? _loadCts;
|
||||
|
||||
public event EventHandler? SelectionChanged;
|
||||
public event EventHandler? FocusAddTaskRequested;
|
||||
public event EventHandler? TasksChanged;
|
||||
public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
public ObservableCollection<TaskRowViewModel> Items { get; } = new();
|
||||
@@ -38,9 +41,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _showOpenLabel;
|
||||
[ObservableProperty] private string _completedHeader = "COMPLETED";
|
||||
|
||||
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient? worker = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_worker = worker;
|
||||
}
|
||||
|
||||
public void LoadForList(ListNavItemViewModel? list)
|
||||
@@ -85,6 +89,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
ListKind.Smart when list.Id == "smart:my-day" => all.Where(t => t.IsMyDay),
|
||||
ListKind.Smart when list.Id == "smart:important" => all.Where(t => t.IsStarred),
|
||||
ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null),
|
||||
ListKind.Virtual when list.Id == "virtual:queued" => all.Where(t => t.Status == TaskStatus.Queued),
|
||||
ListKind.Virtual when list.Id == "virtual:running" => all.Where(t => t.Status == TaskStatus.Running),
|
||||
ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active),
|
||||
ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id),
|
||||
@@ -170,6 +175,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
Regroup();
|
||||
NewTaskTitle = "";
|
||||
UpdateSubtitle();
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public bool CanReorder => _currentList?.Kind == ListKind.User;
|
||||
@@ -199,15 +205,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
if (source.IsRunning || target.IsRunning) return;
|
||||
if (ReferenceEquals(source, target)) return;
|
||||
|
||||
var srcIdx = Items.IndexOf(source);
|
||||
var tgtIdx = Items.IndexOf(target);
|
||||
if (srcIdx < 0 || tgtIdx < 0) return;
|
||||
// Master Items: single Move event (no Reset) so ItemsControls animate, not rebuild.
|
||||
MoveWithinCollection(Items, source, target, placeBelow);
|
||||
|
||||
Items.RemoveAt(srcIdx);
|
||||
var newTgtIdx = Items.IndexOf(target);
|
||||
var insertIdx = placeBelow ? newTgtIdx + 1 : newTgtIdx;
|
||||
if (insertIdx < 0 || insertIdx > Items.Count) insertIdx = Items.Count;
|
||||
Items.Insert(insertIdx, source);
|
||||
// Apply the same move in whichever section the row lives in.
|
||||
// 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);
|
||||
|
||||
var listId = _currentList.Id["user:".Length..];
|
||||
var orderedIds = Items.Select(i => i.Id).ToList();
|
||||
@@ -223,8 +230,33 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
if (e is not null) e.SortOrder = i;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Regroup();
|
||||
private static void MoveWithinCollection(
|
||||
System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel> coll,
|
||||
TaskRowViewModel source,
|
||||
TaskRowViewModel target,
|
||||
bool placeBelow)
|
||||
{
|
||||
var srcIdx = coll.IndexOf(source);
|
||||
var tgtIdx = coll.IndexOf(target);
|
||||
if (srcIdx < 0 || tgtIdx < 0 || srcIdx == tgtIdx) return;
|
||||
|
||||
var finalIdx = placeBelow ? tgtIdx + 1 : tgtIdx;
|
||||
if (srcIdx < finalIdx) finalIdx--;
|
||||
if (finalIdx < 0) finalIdx = 0;
|
||||
if (finalIdx >= coll.Count) finalIdx = coll.Count - 1;
|
||||
if (finalIdx == srcIdx) return;
|
||||
|
||||
coll.Move(srcIdx, finalIdx);
|
||||
}
|
||||
|
||||
private System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel>? SectionFor(TaskRowViewModel row)
|
||||
{
|
||||
if (OverdueItems.Contains(row)) return OverdueItems;
|
||||
if (OpenItems.Contains(row)) return OpenItems;
|
||||
if (CompletedItems.Contains(row)) return CompletedItems;
|
||||
return null;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -241,6 +273,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
}
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -254,8 +287,61 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
entity.IsStarred = row.IsStarred;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SendToQueueAsync(TaskRowViewModel? row)
|
||||
{
|
||||
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);
|
||||
if (entity is null) return;
|
||||
entity.Status = TaskStatus.Queued;
|
||||
await db.SaveChangesAsync();
|
||||
row.Status = TaskStatus.Queued;
|
||||
if (_worker is not null)
|
||||
{
|
||||
try { await _worker.WakeQueueAsync(); } catch { }
|
||||
}
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RemoveFromQueueAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
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;
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when)
|
||||
{
|
||||
if (row is null) return;
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||
if (entity is null) return;
|
||||
entity.ScheduledFor = when;
|
||||
await db.SaveChangesAsync();
|
||||
row.ScheduledFor = when;
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task ClearScheduleAsync(TaskRowViewModel? row) =>
|
||||
row is null ? Task.CompletedTask : SetScheduledForAsync(row, null);
|
||||
|
||||
[RelayCommand]
|
||||
private void Select(TaskRowViewModel row) => SelectedTask = row;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user