From 6f725d12f5b5bac94410cf31818f4835571afa40 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 13:07:48 +0200 Subject: [PATCH] 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. --- src/ClaudeDo.App/Program.cs | 4 +- .../Islands/ListsIslandViewModel.cs | 52 ++++++++- .../ViewModels/Islands/TaskRowViewModel.cs | 9 +- .../Islands/TasksIslandViewModel.cs | 106 ++++++++++++++++-- .../ViewModels/IslandsShellViewModel.cs | 4 +- .../Views/Islands/TaskRowView.axaml | 70 +++++++++++- .../Views/Islands/TaskRowView.axaml.cs | 56 +++++++++ 7 files changed, 280 insertions(+), 21 deletions(-) diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index e82cbc8..26c136a 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -88,7 +88,9 @@ sealed class Program sp, sp.GetRequiredService())); sc.AddSingleton(sp => - new TasksIslandViewModel(sp.GetRequiredService>())); + new TasksIslandViewModel( + sp.GetRequiredService>(), + sp.GetRequiredService())); sc.AddSingleton(sp => new DetailsIslandViewModel( sp.GetRequiredService>(), diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index aaa5b41..430e05a 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -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] diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index 831fda7..2fe8ddc 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -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)); } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index da8c2ee..28f313f 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -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 _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 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 dbFactory) + public TasksIslandViewModel(IDbContextFactory 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 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? 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; diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 37adff9..14d5f49 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -52,10 +52,12 @@ public sealed partial class IslandsShellViewModel : ViewModelBase Lists = lists; Tasks = tasks; Details = details; Worker = worker; Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList); Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask); + Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync(); Details.CloseDetail = () => Tasks.SelectedTask = null; - Details.DeleteFromList = _ => + Details.DeleteFromList = row => { Tasks.LoadForList(Lists.SelectedList); + _ = Lists.RefreshCountsAsync(); return System.Threading.Tasks.Task.CompletedTask; }; Worker.PropertyChanged += (_, e) => diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml index 1ae01d4..f7a7ed1 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml @@ -19,11 +19,22 @@ Margin="0" Classes.selected="{Binding IsSelected}" Classes.done="{Binding Done}"> - - - - + + + + + + + + + + + @@ -129,5 +149,45 @@ + + + diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs index 2a118cb..47f84ce 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs @@ -1,16 +1,72 @@ +using System.Linq; using Avalonia; using Avalonia.Animation; using Avalonia.Animation.Easings; using Avalonia.Controls; +using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Styling; +using Avalonia.VisualTree; +using ClaudeDo.Ui.ViewModels.Islands; namespace ClaudeDo.Ui.Views.Islands; public partial class TaskRowView : UserControl { + private TaskRowViewModel? _pendingScheduleRow; + public TaskRowView() { InitializeComponent(); } + private TasksIslandViewModel? FindTasksVm() => + this.GetVisualAncestors().OfType() + .Select(ic => ic.DataContext).OfType().FirstOrDefault(); + + private async void OnSendToQueueClick(object? sender, RoutedEventArgs e) + { + if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) + await vm.SendToQueueCommand.ExecuteAsync(row); + } + + private async void OnRemoveFromQueueClick(object? sender, RoutedEventArgs e) + { + if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) + await vm.RemoveFromQueueCommand.ExecuteAsync(row); + } + + private async void OnClearScheduleClick(object? sender, RoutedEventArgs e) + { + if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) + await vm.ClearScheduleCommand.ExecuteAsync(row); + } + + private void OnScheduleForClick(object? sender, RoutedEventArgs e) + { + if (DataContext is not TaskRowViewModel row) return; + _pendingScheduleRow = row; + var seed = row.ScheduledFor ?? DateTime.Now.AddHours(1); + ScheduleDate.SelectedDate = new DateTimeOffset(seed.Date, TimeSpan.Zero); + ScheduleTime.SelectedTime = seed.TimeOfDay; + ScheduleAnchor.Flyout?.ShowAt(ScheduleAnchor); + } + + private async void OnScheduleSetClick(object? sender, RoutedEventArgs e) + { + ScheduleAnchor.Flyout?.Hide(); + if (_pendingScheduleRow is null || ScheduleDate.SelectedDate is null) return; + var date = ScheduleDate.SelectedDate.Value.Date; + var time = ScheduleTime.SelectedTime ?? TimeSpan.FromHours(9); + var when = date + time; + if (FindTasksVm() is { } tvm) + await tvm.SetScheduledForAsync(_pendingScheduleRow, when); + _pendingScheduleRow = null; + } + + private void OnScheduleCancelClick(object? sender, RoutedEventArgs e) + { + ScheduleAnchor.Flyout?.Hide(); + _pendingScheduleRow = null; + } + protected override async void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e);