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:
mika kuns
2026-04-23 13:07:48 +02:00
parent 9952ff98f2
commit 6f725d12f5
7 changed files with 280 additions and 21 deletions

View File

@@ -88,7 +88,9 @@ sealed class Program
sp, sp,
sp.GetRequiredService<WorkerClient>())); sp.GetRequiredService<WorkerClient>()));
sc.AddSingleton<TasksIslandViewModel>(sp => sc.AddSingleton<TasksIslandViewModel>(sp =>
new TasksIslandViewModel(sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>())); new TasksIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<WorkerClient>()));
sc.AddSingleton<DetailsIslandViewModel>(sp => sc.AddSingleton<DetailsIslandViewModel>(sp =>
new DetailsIslandViewModel( new DetailsIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(), sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),

View File

@@ -2,6 +2,8 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.ViewModels.Modals;
@@ -68,7 +70,12 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
: Environment.UserName.ToUpperInvariant(); : Environment.UserName.ToUpperInvariant();
if (_worker is not null) 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) 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: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:important", Name = "Important", Kind = ListKind.Smart, IconKey = "Star" },
new ListNavItemViewModel { Id = "smart:planned", Name = "Planned", Kind = ListKind.Smart, IconKey = "Calendar" }, 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:running", Name = "Running", Kind = ListKind.Virtual, IconKey = "Activity" },
new ListNavItemViewModel { Id = "virtual:review", Name = "Review", Kind = ListKind.Virtual, IconKey = "Eye" }, 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) public async Task RefreshCountsAsync(CancellationToken ct = default)
{ {
foreach (var i in Items) i.Count = 0; try
await Task.CompletedTask; {
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] [RelayCommand]

View File

@@ -37,6 +37,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public bool HasSteps => StepsCount > 0; public bool HasSteps => StepsCount > 0;
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 HasSchedule => ScheduledFor.HasValue;
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail); public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
public string DiffAdditionsText => $"+{DiffAdditions}"; public string DiffAdditionsText => $"+{DiffAdditions}";
@@ -56,13 +58,18 @@ public sealed partial class TaskRowViewModel : ViewModelBase
{ {
OnPropertyChanged(nameof(StatusChipClass)); OnPropertyChanged(nameof(StatusChipClass));
OnPropertyChanged(nameof(IsRunning)); OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsQueued));
OnPropertyChanged(nameof(HasLiveTail)); OnPropertyChanged(nameof(HasLiveTail));
} }
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));
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 OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffAdditionsText)); }
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); } partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); }

View File

@@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -12,11 +13,13 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class TasksIslandViewModel : ViewModelBase public sealed partial class TasksIslandViewModel : ViewModelBase
{ {
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorkerClient? _worker;
private ListNavItemViewModel? _currentList; private ListNavItemViewModel? _currentList;
private CancellationTokenSource? _loadCts; private CancellationTokenSource? _loadCts;
public event EventHandler? SelectionChanged; public event EventHandler? SelectionChanged;
public event EventHandler? FocusAddTaskRequested; public event EventHandler? FocusAddTaskRequested;
public event EventHandler? TasksChanged;
public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty); public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty);
public ObservableCollection<TaskRowViewModel> Items { get; } = new(); public ObservableCollection<TaskRowViewModel> Items { get; } = new();
@@ -38,9 +41,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
[ObservableProperty] private bool _showOpenLabel; [ObservableProperty] private bool _showOpenLabel;
[ObservableProperty] private string _completedHeader = "COMPLETED"; [ObservableProperty] private string _completedHeader = "COMPLETED";
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory) public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient? worker = null)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_worker = worker;
} }
public void LoadForList(ListNavItemViewModel? list) 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: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:important" => all.Where(t => t.IsStarred),
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 => t.Status == TaskStatus.Queued),
ListKind.Virtual when list.Id == "virtual:running" => all.Where(t => t.Status == TaskStatus.Running), 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.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), ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id),
@@ -170,6 +175,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
Regroup(); Regroup();
NewTaskTitle = ""; NewTaskTitle = "";
UpdateSubtitle(); UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
} }
public bool CanReorder => _currentList?.Kind == ListKind.User; public bool CanReorder => _currentList?.Kind == ListKind.User;
@@ -199,15 +205,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
if (source.IsRunning || target.IsRunning) return; if (source.IsRunning || target.IsRunning) return;
if (ReferenceEquals(source, target)) return; if (ReferenceEquals(source, target)) return;
var srcIdx = Items.IndexOf(source); // Master Items: single Move event (no Reset) so ItemsControls animate, not rebuild.
var tgtIdx = Items.IndexOf(target); MoveWithinCollection(Items, source, target, placeBelow);
if (srcIdx < 0 || tgtIdx < 0) return;
Items.RemoveAt(srcIdx); // Apply the same move in whichever section the row lives in.
var newTgtIdx = Items.IndexOf(target); // Reorder never changes which section (Open/Overdue/Completed) a row belongs to —
var insertIdx = placeBelow ? newTgtIdx + 1 : newTgtIdx; // that's determined by Done flag and ScheduledFor date, not drag-drop.
if (insertIdx < 0 || insertIdx > Items.Count) insertIdx = Items.Count; var sourceSection = SectionFor(source);
Items.Insert(insertIdx, 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 listId = _currentList.Id["user:".Length..];
var orderedIds = Items.Select(i => i.Id).ToList(); 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; if (e is not null) e.SortOrder = i;
} }
await db.SaveChangesAsync(); 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] [RelayCommand]
@@ -241,6 +273,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
} }
Regroup(); Regroup();
UpdateSubtitle(); UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
} }
[RelayCommand] [RelayCommand]
@@ -254,8 +287,61 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
entity.IsStarred = row.IsStarred; entity.IsStarred = row.IsStarred;
await db.SaveChangesAsync(); 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] [RelayCommand]
private void Select(TaskRowViewModel row) => SelectedTask = row; private void Select(TaskRowViewModel row) => SelectedTask = row;

View File

@@ -52,10 +52,12 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
Lists = lists; Tasks = tasks; Details = details; Worker = worker; Lists = lists; Tasks = tasks; Details = details; Worker = worker;
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList); Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask); Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
Details.CloseDetail = () => Tasks.SelectedTask = null; Details.CloseDetail = () => Tasks.SelectedTask = null;
Details.DeleteFromList = _ => Details.DeleteFromList = row =>
{ {
Tasks.LoadForList(Lists.SelectedList); Tasks.LoadForList(Lists.SelectedList);
_ = Lists.RefreshCountsAsync();
return System.Threading.Tasks.Task.CompletedTask; return System.Threading.Tasks.Task.CompletedTask;
}; };
Worker.PropertyChanged += (_, e) => Worker.PropertyChanged += (_, e) =>

View File

@@ -19,11 +19,22 @@
Margin="0" Margin="0"
Classes.selected="{Binding IsSelected}" Classes.selected="{Binding IsSelected}"
Classes.done="{Binding Done}"> Classes.done="{Binding Done}">
<Grid ColumnDefinitions="4,32,*,32" Margin="6,8,10,8"> <Border.ContextMenu>
<ContextMenu>
<!-- Left accent bar (visible when selected) --> <MenuItem Header="Send to queue"
<Border Grid.Column="0" Classes="task-row-accent" IsVisible="{Binding !IsQueued}"
IsVisible="{Binding IsSelected}"/> Click="OnSendToQueueClick"/>
<MenuItem Header="Remove from queue"
IsVisible="{Binding IsQueued}"
Click="OnRemoveFromQueueClick"/>
<Separator/>
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/>
<MenuItem Header="Clear schedule"
IsVisible="{Binding HasSchedule}"
Click="OnClearScheduleClick"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="0,32,*,32" Margin="6,8,10,8">
<!-- Done toggle --> <!-- Done toggle -->
<Button Grid.Column="1" Classes="flat" VerticalAlignment="Top" <Button Grid.Column="1" Classes="flat" VerticalAlignment="Top"
@@ -53,6 +64,15 @@
<TextBlock Text="{Binding Status}"/> <TextBlock Text="{Binding Status}"/>
</Border> </Border>
<!-- Dequeue button (only when Queued) -->
<Button Classes="icon-btn dequeue-btn"
IsVisible="{Binding IsQueued}"
ToolTip.Tip="Remove from queue"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RemoveFromQueueCommand}"
CommandParameter="{Binding}">
<PathIcon Width="10" Height="10" Data="{StaticResource Icon.X}"/>
</Button>
<!-- List chip with dot --> <!-- List chip with dot -->
<Border Classes="chip chip-list"> <Border Classes="chip chip-list">
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center"> <StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
@@ -129,5 +149,45 @@
<Border Height="2" VerticalAlignment="Center" Margin="4,0" <Border Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"/> Background="{DynamicResource MossBrush}" CornerRadius="1"/>
</Grid> </Grid>
<!-- Hidden schedule anchor (its Flyout is shown from the context menu) -->
<Button Grid.Row="1" x:Name="ScheduleAnchor"
Width="1" Height="1" Opacity="0"
HorizontalAlignment="Left" VerticalAlignment="Top"
IsHitTestVisible="False" Focusable="False">
<Button.Flyout>
<Flyout Placement="Bottom" ShowMode="Standard">
<Border Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="1" CornerRadius="10"
Padding="16" Width="300">
<StackPanel Spacing="12">
<TextBlock Text="Schedule task"
FontWeight="SemiBold" FontSize="13"
Foreground="{DynamicResource TextBrush}"/>
<StackPanel Spacing="6">
<TextBlock Text="DATE" FontSize="10" Opacity="0.6"
Foreground="{DynamicResource TextDimBrush}"/>
<DatePicker x:Name="ScheduleDate" HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="TIME" FontSize="10" Opacity="0.6"
Foreground="{DynamicResource TextDimBrush}"/>
<TimePicker x:Name="ScheduleTime" ClockIdentifier="24HourClock"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" Margin="0,4,0,0">
<Button Content="Cancel" Click="OnScheduleCancelClick" MinWidth="76"/>
<Button Content="Schedule" Classes="accent" Click="OnScheduleSetClick" MinWidth="76"/>
</StackPanel>
</StackPanel>
</Border>
</Flyout>
</Button.Flyout>
</Button>
</Grid> </Grid>
</UserControl> </UserControl>

View File

@@ -1,16 +1,72 @@
using System.Linq;
using Avalonia; using Avalonia;
using Avalonia.Animation; using Avalonia.Animation;
using Avalonia.Animation.Easings; using Avalonia.Animation.Easings;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Styling; using Avalonia.Styling;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands; namespace ClaudeDo.Ui.Views.Islands;
public partial class TaskRowView : UserControl public partial class TaskRowView : UserControl
{ {
private TaskRowViewModel? _pendingScheduleRow;
public TaskRowView() { InitializeComponent(); } public TaskRowView() { InitializeComponent(); }
private TasksIslandViewModel? FindTasksVm() =>
this.GetVisualAncestors().OfType<ItemsControl>()
.Select(ic => ic.DataContext).OfType<TasksIslandViewModel>().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) protected override async void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{ {
base.OnAttachedToVisualTree(e); base.OnAttachedToVisualTree(e);