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:
@@ -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>>(),
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)); }
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user