feat(ui): My Day actions, orphan-aware grouping, menu restructure
Pending UI work: - My Day add/remove context actions on task rows (parent removal cascades to children) - orphan-aware grouping: a child whose parent isn't in view renders as a top-level row, not an indented draft - shell menu restructure (Worker / Repositories submenus); 'Finalize plan' action, drop 'Queue subtasks sequentially' - notes editor refinements - subtask-row hover tweak (Surface3, no transition) - bump Avalonia 12.0.0 -> 12.0.4
This commit is contained in:
@@ -7,24 +7,15 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public sealed partial class NoteBulletViewModel : ViewModelBase
|
||||
{
|
||||
private readonly Func<NoteBulletViewModel, Task> _save;
|
||||
private readonly Func<NoteBulletViewModel, Task> _delete;
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
[ObservableProperty] private string _text;
|
||||
|
||||
public NoteBulletViewModel(string id, string text,
|
||||
Func<NoteBulletViewModel, Task> save, Func<NoteBulletViewModel, Task> delete)
|
||||
public NoteBulletViewModel(string id, string text)
|
||||
{
|
||||
Id = id;
|
||||
_text = text;
|
||||
_save = save;
|
||||
_delete = delete;
|
||||
}
|
||||
|
||||
[RelayCommand] private Task Save() => _save(this);
|
||||
[RelayCommand] private Task Delete() => _delete(this);
|
||||
}
|
||||
|
||||
public sealed partial class NotesEditorViewModel : ViewModelBase
|
||||
@@ -57,7 +48,7 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
private NoteBulletViewModel MakeBullet(string id, string text) =>
|
||||
new(id, text, SaveBulletAsync, DeleteBulletAsync);
|
||||
new(id, text);
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddBullet()
|
||||
@@ -73,11 +64,17 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
|
||||
[RelayCommand] private Task NextDay() => LoadDayAsync(CurrentDay.AddDays(1));
|
||||
[RelayCommand] private Task Today() => LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
|
||||
|
||||
private Task SaveBulletAsync(NoteBulletViewModel b) => _api.UpdateAsync(b.Id, b.Text);
|
||||
|
||||
private async Task DeleteBulletAsync(NoteBulletViewModel b)
|
||||
[RelayCommand]
|
||||
private async Task CommitBullet(NoteBulletViewModel? b)
|
||||
{
|
||||
await _api.DeleteAsync(b.Id);
|
||||
Bullets.Remove(b);
|
||||
if (b is null) return;
|
||||
var text = b.Text?.Trim() ?? "";
|
||||
if (text.Length == 0)
|
||||
{
|
||||
await _api.DeleteAsync(b.Id);
|
||||
Bullets.Remove(b);
|
||||
return;
|
||||
}
|
||||
await _api.UpdateAsync(b.Id, text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _hasQueuedSubtasks;
|
||||
[ObservableProperty] private bool _showListChip = true;
|
||||
[ObservableProperty] private bool _parentFinalized;
|
||||
[ObservableProperty] private bool _parentInView = true;
|
||||
[ObservableProperty] private int _roadblockCount;
|
||||
[ObservableProperty] private bool _isRefining;
|
||||
|
||||
@@ -46,9 +47,13 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public bool IsAgentSuggested => IsChild && !string.IsNullOrEmpty(CreatedBy) && CreatedBy == ParentTaskId;
|
||||
public bool IsPlanningParent => PlanningPhase != PlanningPhase.None
|
||||
|| HasPlanningChildren;
|
||||
// A child only reads as a child while its parent shares the current view. When the parent is
|
||||
// absent (removed from My Day, or daily-prep placed a lone child there), the row renders as a
|
||||
// normal top-level task instead of an orphaned, indented Draft.
|
||||
public bool ShowAsChild => IsChild && ParentInView;
|
||||
// A subtask is Draft until its planning parent is finalized, then Planned (queueable).
|
||||
public bool IsDraft => IsChild && Status == TaskStatus.Idle && !ParentFinalized;
|
||||
public bool IsPlanned => IsChild && Status == TaskStatus.Idle && ParentFinalized;
|
||||
public bool IsDraft => ShowAsChild && Status == TaskStatus.Idle && !ParentFinalized;
|
||||
public bool IsPlanned => ShowAsChild && Status == TaskStatus.Idle && ParentFinalized;
|
||||
|
||||
public bool CanOpenPlanningSession => Status == TaskStatus.Idle
|
||||
&& PlanningPhase == PlanningPhase.None
|
||||
@@ -74,13 +79,23 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
||||
// "Send to queue" is the single queue entry. On a finalized planning parent it queues the
|
||||
// plan (children) via CanQueuePlan; an Active (not-yet-finalized) planning parent is hidden —
|
||||
// it must be finalized first.
|
||||
public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks
|
||||
&& (!IsChild || ParentFinalized);
|
||||
&& (!IsChild || ParentFinalized)
|
||||
&& PlanningPhase != PlanningPhase.Active;
|
||||
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
|
||||
// Drives the routing inside SendToQueue, not a separate menu entry.
|
||||
public bool CanQueuePlan => !IsChild && HasPlanningChildren
|
||||
&& PlanningPhase == PlanningPhase.Finalized
|
||||
&& !HasQueuedSubtasks;
|
||||
// User-triggered finalize for a planning parent whose session was closed before finalizing.
|
||||
public bool CanFinalizePlanning => PlanningPhase == PlanningPhase.Active && !IsChild;
|
||||
public bool HasSchedule => ScheduledFor.HasValue;
|
||||
// "Add to My Day" — shown on any task not already in My Day; a Done task has no place in
|
||||
// today's focus list. The mirror of "Remove from My Day" (gated on IsMyDay).
|
||||
public bool CanAddToMyDay => !IsMyDay && !Done;
|
||||
public bool HasRoadblock => RoadblockCount > 0;
|
||||
public string RoadblockTooltip => RoadblockCount == 1
|
||||
? "1 roadblock reported during the run — see details"
|
||||
@@ -135,12 +150,20 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
{
|
||||
OnPropertyChanged(nameof(IsChild));
|
||||
OnPropertyChanged(nameof(IsAgentSuggested));
|
||||
OnPropertyChanged(nameof(ShowAsChild));
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
OnPropertyChanged(nameof(IsPlanned));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
}
|
||||
|
||||
partial void OnParentInViewChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowAsChild));
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
OnPropertyChanged(nameof(IsPlanned));
|
||||
}
|
||||
|
||||
partial void OnCreatedByChanged(string? value) => OnPropertyChanged(nameof(IsAgentSuggested));
|
||||
|
||||
partial void OnParentFinalizedChanged(bool value)
|
||||
@@ -159,6 +182,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
|
||||
OnPropertyChanged(nameof(CanQueuePlan));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
OnPropertyChanged(nameof(CanFinalizePlanning));
|
||||
OnPropertyChanged(nameof(CanRefine));
|
||||
}
|
||||
|
||||
@@ -185,7 +210,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
||||
partial void OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue));
|
||||
partial void OnDoneChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsOverdue));
|
||||
OnPropertyChanged(nameof(CanAddToMyDay));
|
||||
}
|
||||
partial void OnIsMyDayChanged(bool value) => OnPropertyChanged(nameof(CanAddToMyDay));
|
||||
partial void OnScheduledForChanged(DateTime? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsOverdue));
|
||||
|
||||
@@ -334,6 +334,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
// Items is already ordered by SortOrder from the DB query.
|
||||
// Treat rows whose ParentTaskId is not in the current view as orphans -> top-level.
|
||||
var visibleIds = Items.Select(r => r.Id).ToHashSet();
|
||||
// A child reads as a child only while its parent is in the view. Flag orphans so they
|
||||
// render flat (no indent, no Draft/Planned badge) instead of breaking the layout.
|
||||
foreach (var r in Items)
|
||||
r.ParentInView = string.IsNullOrEmpty(r.ParentTaskId) || visibleIds.Contains(r.ParentTaskId!);
|
||||
bool IsTopLevel(TaskRowViewModel r) =>
|
||||
!r.IsChild
|
||||
|| string.IsNullOrEmpty(r.ParentTaskId)
|
||||
@@ -571,6 +575,52 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddToMyDayAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || row.IsMyDay) return;
|
||||
row.IsMyDay = true;
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||
if (entity != null)
|
||||
{
|
||||
entity.IsMyDay = true;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RemoveFromMyDayAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
row.IsMyDay = false;
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
// Removing a parent takes its whole plan off My Day: clear the task and every child, so no
|
||||
// orphaned child is left behind (independently-IsMyDay children included). A leaf child has
|
||||
// no children of its own, so this collapses to just clearing the row itself.
|
||||
var affected = await db.Tasks
|
||||
.Where(t => t.Id == row.Id || t.ParentTaskId == row.Id)
|
||||
.ToListAsync();
|
||||
foreach (var t in affected)
|
||||
t.IsMyDay = false;
|
||||
if (affected.Count > 0)
|
||||
await db.SaveChangesAsync();
|
||||
if (_currentList?.Id == "smart:my-day")
|
||||
{
|
||||
var drop = Items
|
||||
.Where(r => r.Id == row.Id || r.ParentTaskId == row.Id)
|
||||
.ToList();
|
||||
foreach (var r in drop)
|
||||
Items.Remove(r);
|
||||
}
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public async Task SetStatusOnRowAsync(TaskRowViewModel row, TaskStatus status)
|
||||
{
|
||||
if (_worker is null) return;
|
||||
@@ -582,6 +632,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
private async Task SendToQueueAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || row.IsRunning) return;
|
||||
// A finalized planning parent queues its plan (children sequentially), not itself.
|
||||
if (row.CanQueuePlan)
|
||||
{
|
||||
await QueuePlanningSubtasksAsync(row);
|
||||
return;
|
||||
}
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||
if (entity is null) return;
|
||||
|
||||
Reference in New Issue
Block a user