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:
Mika Kuns
2026-06-18 16:22:29 +02:00
parent 43fb506e87
commit 4847c5c0a4
19 changed files with 384 additions and 58 deletions

View File

@@ -7,7 +7,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.0" />
<PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />

View File

@@ -871,14 +871,9 @@
<Setter Property="Padding" Value="8,5" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.10"/>
</Transitions>
</Setter>
</Style>
<Style Selector="Border.subtask-row:pointerover">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
</Style>
<Style Selector="Border.subtask-row.done TextBlock.subtask-title">
<Setter Property="Opacity" Value="0.5" />

View File

@@ -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);
}
}

View File

@@ -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));

View File

@@ -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;

View File

@@ -29,7 +29,9 @@ public partial class DescriptionStepsCard : UserControl
private void OnSubtaskEditLostFocus(object? sender, RoutedEventArgs e)
{
if (sender is TextBox { DataContext: SubtaskRowViewModel row })
row.IsEditing = false;
if (sender is TextBox { DataContext: SubtaskRowViewModel row }
&& DataContext is DetailsIslandViewModel vm
&& vm.CommitSubtaskEditCommand.CanExecute(row))
vm.CommitSubtaskEditCommand.Execute(row);
}
}

View File

@@ -35,6 +35,13 @@ public partial class DetailsIslandView : UserControl
if (h <= 0) return;
DetailBodyGrid.RowDefinitions[0].MaxHeight = h * 2.0 / 3.0;
DetailBodyGrid.RowDefinitions[1].MinHeight = h / 3.0;
// The description sits in an Auto row, which measures its cell with
// infinite height — so the card's inner ScrollViewer thinks everything
// fits and never scrolls. Bounding the card itself gives that
// ScrollViewer a finite measure constraint so it engages once the
// content exceeds 2/3 of the island. (RowDefinition.MaxHeight above only
// clamps the drag and the final row height, not the measure constraint.)
DescriptionCard.MaxHeight = h * 2.0 / 3.0;
}
private void OnDataContextChanged(object? sender, EventArgs e)

View File

@@ -28,11 +28,8 @@
<ItemsControl ItemsSource="{Binding Bullets}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:NoteBulletViewModel">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2" ColumnSpacing="6">
<TextBox Grid.Column="0" Text="{Binding Text}"/>
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr notes.save}" Command="{Binding SaveCommand}"/>
<Button Grid.Column="2" Classes="btn" Content="{loc:Tr notes.delete}" Command="{Binding DeleteCommand}"/>
</Grid>
<TextBox Text="{Binding Text}" Margin="0,2"
LostFocus="OnBulletLostFocus"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>

View File

@@ -1,8 +1,18 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands;
public partial class NotesEditorView : UserControl
{
public NotesEditorView() => InitializeComponent();
private void OnBulletLostFocus(object? sender, RoutedEventArgs e)
{
if (sender is TextBox { DataContext: NoteBulletViewModel bullet }
&& DataContext is NotesEditorViewModel vm
&& vm.CommitBulletCommand.CanExecute(bullet))
vm.CommitBulletCommand.Execute(bullet);
}
}

View File

@@ -20,8 +20,8 @@
<!-- Indent wrapper: col 0 = 24px child indent track, col 1 = content -->
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
<!-- Indent track (only visible for child tasks) -->
<Border Grid.Column="0" Width="24" IsVisible="{Binding IsChild}" VerticalAlignment="Stretch">
<!-- Indent track (only while the parent shares this view; orphaned children render flat) -->
<Border Grid.Column="0" Width="24" IsVisible="{Binding ShowAsChild}" VerticalAlignment="Stretch">
<Rectangle Width="1" Fill="{DynamicResource LineBrush}"
HorizontalAlignment="Right" Margin="0,4"/>
</Border>
@@ -56,17 +56,23 @@
<MenuItem Header="{loc:Tr tasks.ctxResumePlanningSession}"
Click="OnResumePlanningSessionClick"
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
<MenuItem Header="{loc:Tr tasks.ctxFinalizePlanningSession}"
Click="OnFinalizePlanningSessionClick"
IsVisible="{Binding CanFinalizePlanning}"/>
<MenuItem Header="{loc:Tr tasks.ctxDiscardPlanningSession}"
Click="OnDiscardPlanningSessionClick"
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
<MenuItem Header="{loc:Tr tasks.ctxQueueSubtasks}"
Click="OnQueuePlanningSubtasksClick"
IsVisible="{Binding CanQueuePlan}"/>
<Separator/>
<MenuItem Header="{loc:Tr tasks.ctxScheduleFor}" Click="OnScheduleForClick"/>
<MenuItem Header="{loc:Tr tasks.ctxClearSchedule}"
IsVisible="{Binding HasSchedule}"
Click="OnClearScheduleClick"/>
<MenuItem Header="{loc:Tr tasks.ctxAddToMyDay}"
IsVisible="{Binding CanAddToMyDay}"
Click="OnAddToMyDayClick"/>
<MenuItem Header="{loc:Tr tasks.ctxRemoveFromMyDay}"
IsVisible="{Binding IsMyDay}"
Click="OnRemoveFromMyDayClick"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">

View File

@@ -48,6 +48,18 @@ public partial class TaskRowView : UserControl
await vm.ClearScheduleCommand.ExecuteAsync(row);
}
private async void OnAddToMyDayClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.AddToMyDayCommand.ExecuteAsync(row);
}
private async void OnRemoveFromMyDayClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.RemoveFromMyDayCommand.ExecuteAsync(row);
}
private async void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
@@ -72,10 +84,10 @@ public partial class TaskRowView : UserControl
await vm.DiscardPlanningSessionCommand.ExecuteAsync(row);
}
private async void OnQueuePlanningSubtasksClick(object? sender, RoutedEventArgs e)
private async void OnFinalizePlanningSessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.QueuePlanningSubtasksCommand.ExecuteAsync(row);
await vm.FinalizePlanningSessionCommand.ExecuteAsync(row);
}
private async void OnSetStatusClick(object? sender, RoutedEventArgs e)

View File

@@ -57,18 +57,26 @@
<Menu Margin="12,0,0,0"
Background="Transparent"
VerticalAlignment="Center">
<MenuItem Header="{loc:Tr shell.menu.worker}"
FontSize="{StaticResource FontSizeMono}"
Foreground="{DynamicResource TextDimBrush}">
<MenuItem Header="{loc:Tr shell.menu.restartWorker}"
Command="{Binding RestartWorkerCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.checkForUpdates}"
Command="{Binding CheckForUpdatesCommand}"/>
</MenuItem>
<MenuItem Header="{loc:Tr shell.menu.repositories}"
FontSize="{StaticResource FontSizeMono}"
Foreground="{DynamicResource TextDimBrush}">
<MenuItem Header="{loc:Tr shell.menu.addRepos}" Command="{Binding OpenRepoImportCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.worktrees}"
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
</MenuItem>
<MenuItem Header="{loc:Tr shell.menu.help}"
FontSize="{StaticResource FontSizeMono}"
Foreground="{DynamicResource TextDimBrush}">
<MenuItem Header="{loc:Tr shell.menu.checkForUpdates}"
Command="{Binding CheckForUpdatesCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.restartWorker}"
Command="{Binding RestartWorkerCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.worktrees}"
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.weeklyReport}" Command="{Binding OpenWeeklyReportCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.about}" Command="{Binding OpenAboutCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.addRepos}" Command="{Binding OpenRepoImportCommand}"/>
</MenuItem>
</Menu>
</StackPanel>