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:
@@ -14,10 +14,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
|
||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.0">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
|
||||
@@ -110,10 +110,12 @@
|
||||
"ctxRunInteractively": "Interaktiv ausführen",
|
||||
"ctxOpenPlanningSession": "Planungssitzung öffnen",
|
||||
"ctxResumePlanningSession": "Planungssitzung fortsetzen",
|
||||
"ctxFinalizePlanningSession": "Plan finalisieren",
|
||||
"ctxDiscardPlanningSession": "Planungssitzung verwerfen",
|
||||
"ctxQueueSubtasks": "Teilaufgaben nacheinander einreihen",
|
||||
"ctxScheduleFor": "Planen für...",
|
||||
"ctxClearSchedule": "Zeitplan entfernen",
|
||||
"ctxRemoveFromMyDay": "Aus Mein Tag entfernen",
|
||||
"ctxAddToMyDay": "Zu Mein Tag hinzufügen",
|
||||
"badgeDraft": "ENTWURF",
|
||||
"badgePlanned": "GEPLANT",
|
||||
"approve": "Genehmigen",
|
||||
@@ -421,6 +423,8 @@
|
||||
"shell": {
|
||||
"menu": {
|
||||
"help": "Hilfe",
|
||||
"worker": "Worker",
|
||||
"repositories": "Repositories",
|
||||
"checkForUpdates": "Nach Updates suchen",
|
||||
"restartWorker": "Worker neu starten",
|
||||
"worktrees": "Worktrees…",
|
||||
|
||||
@@ -110,10 +110,12 @@
|
||||
"ctxRunInteractively": "Run interactively",
|
||||
"ctxOpenPlanningSession": "Open planning Session",
|
||||
"ctxResumePlanningSession": "Resume planning Session",
|
||||
"ctxFinalizePlanningSession": "Finalize plan",
|
||||
"ctxDiscardPlanningSession": "Discard planning session",
|
||||
"ctxQueueSubtasks": "Queue subtasks sequentially",
|
||||
"ctxScheduleFor": "Schedule for...",
|
||||
"ctxClearSchedule": "Clear schedule",
|
||||
"ctxRemoveFromMyDay": "Remove from My Day",
|
||||
"ctxAddToMyDay": "Add to My Day",
|
||||
"badgeDraft": "DRAFT",
|
||||
"badgePlanned": "PLANNED",
|
||||
"approve": "Approve",
|
||||
@@ -421,6 +423,8 @@
|
||||
"shell": {
|
||||
"menu": {
|
||||
"help": "Help",
|
||||
"worker": "Worker",
|
||||
"repositories": "Repositories",
|
||||
"checkForUpdates": "Check for updates",
|
||||
"restartWorker": "Restart worker",
|
||||
"worktrees": "Worktrees…",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia.Headless" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Headless" Version="12.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
|
||||
@@ -62,7 +62,7 @@ public class NotesEditorViewModelTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteBullet_RemovesFromStoreAndList()
|
||||
public async Task CommitBullet_EmptyText_RemovesFromStoreAndList()
|
||||
{
|
||||
var api = new FakeNotes();
|
||||
var vm = new NotesEditorViewModel(api);
|
||||
@@ -70,9 +70,26 @@ public class NotesEditorViewModelTests
|
||||
vm.NewBulletText = "weg damit";
|
||||
await vm.AddBulletCommand.ExecuteAsync(null);
|
||||
|
||||
await vm.Bullets[0].DeleteCommand.ExecuteAsync(null);
|
||||
vm.Bullets[0].Text = " ";
|
||||
await vm.CommitBulletCommand.ExecuteAsync(vm.Bullets[0]);
|
||||
|
||||
Assert.Empty(vm.Bullets);
|
||||
Assert.Empty(api.Store);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CommitBullet_NonEmptyText_PersistsEdit()
|
||||
{
|
||||
var api = new FakeNotes();
|
||||
var vm = new NotesEditorViewModel(api);
|
||||
await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
|
||||
vm.NewBulletText = "original";
|
||||
await vm.AddBulletCommand.ExecuteAsync(null);
|
||||
|
||||
vm.Bullets[0].Text = "geändert";
|
||||
await vm.CommitBulletCommand.ExecuteAsync(vm.Bullets[0]);
|
||||
|
||||
Assert.Single(vm.Bullets);
|
||||
Assert.Equal("geändert", api.Store[0].Text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,4 +109,83 @@ public class TaskRowViewModelPlanningTests
|
||||
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
||||
Assert.False(vm.CanQueuePlan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivePlanningParent_CannotSendToQueue()
|
||||
{
|
||||
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Active);
|
||||
vm.HasPlanningChildren = true;
|
||||
Assert.False(vm.CanSendToQueue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FinalizedParentWithChildren_CanSendToQueue()
|
||||
{
|
||||
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
||||
vm.HasPlanningChildren = true;
|
||||
Assert.True(vm.CanSendToQueue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivePlanning_CanFinalizePlanning()
|
||||
{
|
||||
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Active);
|
||||
Assert.True(vm.CanFinalizePlanning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FinalizedPlanning_CannotFinalizePlanning()
|
||||
{
|
||||
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
||||
Assert.False(vm.CanFinalizePlanning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlainIdle_CannotFinalizePlanning()
|
||||
{
|
||||
var vm = MakeRow(TaskStatus.Idle);
|
||||
Assert.False(vm.CanFinalizePlanning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChildWithParentInView_RendersAsChild()
|
||||
{
|
||||
var vm = MakeRow(TaskStatus.Idle, parentTaskId: "parent-id");
|
||||
Assert.True(vm.ParentInView); // default
|
||||
Assert.True(vm.ShowAsChild);
|
||||
Assert.True(vm.IsDraft);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrphanedChild_RendersFlat_WithNoDraftOrPlannedBadge()
|
||||
{
|
||||
// Parent absent from the view (e.g. removed from My Day, or daily-prep placed a lone
|
||||
// child there): the row stays a child by data but must read as a normal top-level task.
|
||||
var draftOrphan = MakeRow(TaskStatus.Idle, parentTaskId: "missing");
|
||||
draftOrphan.ParentInView = false;
|
||||
Assert.True(draftOrphan.IsChild);
|
||||
Assert.False(draftOrphan.ShowAsChild);
|
||||
Assert.False(draftOrphan.IsDraft);
|
||||
|
||||
var plannedOrphan = MakeRow(TaskStatus.Idle, parentTaskId: "missing");
|
||||
plannedOrphan.ParentFinalized = true;
|
||||
plannedOrphan.ParentInView = false;
|
||||
Assert.False(plannedOrphan.ShowAsChild);
|
||||
Assert.False(plannedOrphan.IsPlanned);
|
||||
Assert.False(plannedOrphan.IsDraft);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanAddToMyDay_TrueOnlyWhenNotInMyDayAndNotDone()
|
||||
{
|
||||
var row = MakeRow(TaskStatus.Idle);
|
||||
Assert.True(row.CanAddToMyDay); // idle, not yet in My Day
|
||||
|
||||
row.IsMyDay = true;
|
||||
Assert.False(row.CanAddToMyDay); // already in My Day
|
||||
|
||||
row.IsMyDay = false;
|
||||
row.Done = true;
|
||||
Assert.False(row.CanAddToMyDay); // done tasks don't belong in today's focus
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
@@ -223,4 +224,105 @@ public class TasksIslandViewModelPlanningTests
|
||||
vm.ToggleExpandCommand.Execute(parent);
|
||||
Assert.Contains(child, vm.OpenItems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Regroup_ChildWithoutParentInView_ReadsAsTopLevelOrphan()
|
||||
{
|
||||
// Parent not in the view: the child must be flagged orphan so it renders flat, and it
|
||||
// surfaces as a normal top-level row rather than an indented Draft.
|
||||
var orphan = MakeRow("c1", TaskStatus.Idle, parentId: "missing-parent");
|
||||
|
||||
var (vm, _) = VmFactory.Create([orphan]);
|
||||
|
||||
Assert.False(orphan.ParentInView);
|
||||
Assert.False(orphan.ShowAsChild);
|
||||
Assert.False(orphan.IsDraft);
|
||||
Assert.Contains(orphan, vm.OpenItems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Regroup_ChildWithParentPresent_KeepsParentInView()
|
||||
{
|
||||
var parent = MakeRow("p1", TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
||||
var child = MakeRow("c1", TaskStatus.Idle, "p1");
|
||||
|
||||
var (_, _) = VmFactory.Create([parent, child]);
|
||||
|
||||
Assert.True(child.ParentInView);
|
||||
Assert.True(child.ShowAsChild);
|
||||
}
|
||||
}
|
||||
|
||||
// ── My Day add / remove (real DB) ─────────────────────────────────────────────
|
||||
|
||||
public sealed class TasksIslandViewModelMyDayTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
[Fact]
|
||||
public async Task AddToMyDay_SetsIsMyDay()
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString("N");
|
||||
var taskId = Guid.NewGuid().ToString("N");
|
||||
await using (var ctx = _db.CreateContext())
|
||||
{
|
||||
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = taskId, ListId = listId, Title = "T", CreatedAt = DateTime.UtcNow,
|
||||
Status = TaskStatus.Idle, IsMyDay = false,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var vm = new TasksIslandViewModel(_db.CreateFactory(), new FakeWorkerClient());
|
||||
var row = new TaskRowViewModel { Id = taskId, Status = TaskStatus.Idle, IsMyDay = false };
|
||||
|
||||
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.AddToMyDayCommand).ExecuteAsync(row);
|
||||
|
||||
await using var verify = _db.CreateContext();
|
||||
var loaded = await verify.Tasks.FirstAsync(t => t.Id == taskId);
|
||||
Assert.True(loaded.IsMyDay);
|
||||
Assert.True(row.IsMyDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveFromMyDay_OnParent_CascadesToEveryChild()
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString("N");
|
||||
var parentId = Guid.NewGuid().ToString("N");
|
||||
var child1 = Guid.NewGuid().ToString("N");
|
||||
var child2 = Guid.NewGuid().ToString("N");
|
||||
|
||||
await using (var ctx = _db.CreateContext())
|
||||
{
|
||||
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = parentId, ListId = listId, Title = "P", CreatedAt = DateTime.UtcNow,
|
||||
Status = TaskStatus.WaitingForChildren, PlanningPhase = PlanningPhase.Finalized, IsMyDay = true,
|
||||
});
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = child1, ListId = listId, Title = "C1", CreatedAt = DateTime.UtcNow,
|
||||
Status = TaskStatus.Idle, ParentTaskId = parentId, IsMyDay = true,
|
||||
});
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = child2, ListId = listId, Title = "C2", CreatedAt = DateTime.UtcNow,
|
||||
Status = TaskStatus.Idle, ParentTaskId = parentId, IsMyDay = true,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var vm = new TasksIslandViewModel(_db.CreateFactory(), new FakeWorkerClient());
|
||||
var parentRow = new TaskRowViewModel { Id = parentId, Status = TaskStatus.WaitingForChildren, IsMyDay = true };
|
||||
|
||||
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.RemoveFromMyDayCommand).ExecuteAsync(parentRow);
|
||||
|
||||
await using var verify = _db.CreateContext();
|
||||
Assert.False(await verify.Tasks.AnyAsync(t => t.IsMyDay),
|
||||
"removing the parent from My Day must clear IsMyDay on the parent and all its children");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user