diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json
index 9efceff1..18e975a0 100644
--- a/src/ClaudeDo.Localization/locales/de.json
+++ b/src/ClaudeDo.Localization/locales/de.json
@@ -167,7 +167,23 @@
"manualTip": "Manuelle Erinnerung — Claude nimmt sie nie auf",
"ctxMarkManual": "Als manuell markieren",
"ctxMarkClaudeTask": "Als Claude-Aufgabe markieren",
+ "ctxRefineTask": "Aufgabe verfeinern",
+ "ctxPlanning": "Planung",
+ "ctxSchedule": "Zeitplan",
"ctxDeleteTask": "Aufgabe löschen",
+ "reasonAlreadyRunning": "Aufgabe läuft bereits",
+ "reasonAlreadyQueued": "Bereits in der Warteschlange",
+ "reasonWaitingForReview": "Wartet auf Prüfung",
+ "reasonSubtasksQueued": "Teilaufgaben sind bereits eingereiht",
+ "reasonPlanNotFinalized": "Plan ist noch nicht finalisiert",
+ "reasonPlanningActive": "Planungssitzung ist aktiv — zuerst finalisieren",
+ "reasonInteractiveSession": "Für diese Aufgabe ist eine interaktive Sitzung offen",
+ "reasonNotRunning": "Aufgabe läuft nicht",
+ "reasonRefining": "Verfeinerung läuft bereits",
+ "reasonNotIdle": "Nur im Leerlauf verfügbar",
+ "reasonIsPlanningParent": "Teil eines Plans",
+ "reasonIsChildTask": "Teilaufgaben haben keine eigene Planungssitzung",
+ "reasonPlanAlreadyFinalized": "Plan ist bereits finalisiert",
"approve": "Genehmigen",
"approveTip": "Genehmigen — als Erledigt markieren",
"reject": "Ablehnen",
diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json
index e9a07b2c..43735c28 100644
--- a/src/ClaudeDo.Localization/locales/en.json
+++ b/src/ClaudeDo.Localization/locales/en.json
@@ -167,7 +167,23 @@
"manualTip": "Manual reminder — Claude never picks this up",
"ctxMarkManual": "Mark as manual",
"ctxMarkClaudeTask": "Mark as Claude task",
+ "ctxRefineTask": "Refine task",
+ "ctxPlanning": "Planning",
+ "ctxSchedule": "Schedule",
"ctxDeleteTask": "Delete task",
+ "reasonAlreadyRunning": "Task is already running",
+ "reasonAlreadyQueued": "Already in the queue",
+ "reasonWaitingForReview": "Waiting for review",
+ "reasonSubtasksQueued": "Subtasks are already queued",
+ "reasonPlanNotFinalized": "Plan isn't finalized yet",
+ "reasonPlanningActive": "Planning session is active — finalize the plan first",
+ "reasonInteractiveSession": "An interactive session is open for this task",
+ "reasonNotRunning": "Task isn't running",
+ "reasonRefining": "Refine is already running",
+ "reasonNotIdle": "Only available for idle tasks",
+ "reasonIsPlanningParent": "Part of a plan",
+ "reasonIsChildTask": "Subtasks don't have their own planning session",
+ "reasonPlanAlreadyFinalized": "Plan is already finalized",
"approve": "Approve",
"approveTip": "Approve — mark Done",
"reject": "Reject",
diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs
index 57b52327..cd35ea5f 100644
--- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs
@@ -58,6 +58,11 @@ public sealed partial class TaskRowViewModel : ViewModelBase
// Set by the custom drag while this row is being dragged — drives the "grabbed" row style.
[ObservableProperty] private bool _isDragging;
+ // Set from PointerEntered/PointerExited in TaskRowView.axaml.cs. Drives ShowRowActions
+ // together with IsSelected, so hover-only affordances (refine button, unset star, dequeue-X)
+ // stay reachable via keyboard/selection, not just mouse hover.
+ [ObservableProperty] private bool _isHovered;
+
// Transient: set from HubBroadcaster's OperationProgress while the worker is still creating
// this task's worktree (the silent gap between Queued and the first agent output). Cleared
// by the next entity refresh — UpdateFromEntity always reflects a settled state, so there's
@@ -68,6 +73,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
// suppress the ordinary hover highlight/transitions so they don't fight the hint.
public bool IsDropTarget => DropHintAbove || DropHintBelow;
+ // Hover-or-selected gate for the row's optional action affordances (refine button, unset
+ // star, dequeue-X) — a set star and the IsRefining spinner are exempt, they show always.
+ public bool ShowRowActions => IsHovered || IsSelected;
+
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None
&& !IsRefining && !IsManual;
@@ -142,6 +151,60 @@ public sealed partial class TaskRowViewModel : ViewModelBase
&& !HasQueuedSubtasks;
// User-triggered finalize for a planning parent whose session was closed before finalizing.
public bool CanFinalizePlanning => PlanningPhase == PlanningPhase.Active && !IsChild;
+
+ // Context-menu Group 1 reasons: null when the paired CanX is true, otherwise the specific
+ // blocking condition — shown as a disabled MenuItem's tooltip (ToolTip.ShowOnDisabled="True",
+ // verified headlessly to actually render on Avalonia 12 — see task A3). Each mirrors the
+ // negation of its CanX so exactly one branch matches whenever CanX is false.
+ public string? SendToQueueDisabledReason
+ {
+ get
+ {
+ if (CanSendToQueue) return null;
+ if (IsRunning) return Loc.T("tasks.reasonAlreadyRunning");
+ if (IsQueued) return Loc.T("tasks.reasonAlreadyQueued");
+ if (IsWaitingForReview) return Loc.T("tasks.reasonWaitingForReview");
+ if (HasQueuedSubtasks) return Loc.T("tasks.reasonSubtasksQueued");
+ if (IsChild && !ParentFinalized) return Loc.T("tasks.reasonPlanNotFinalized");
+ if (PlanningPhase == PlanningPhase.Active) return Loc.T("tasks.reasonPlanningActive");
+ if (IsManual) return Loc.T("tasks.manualTip");
+ if (HasInteractiveSession) return Loc.T("tasks.reasonInteractiveSession");
+ return null;
+ }
+ }
+
+ public string? CancelDisabledReason => IsRunning ? null : Loc.T("tasks.reasonNotRunning");
+
+ // "Open quick session" has no precondition today (see A1's file header) — kept as a
+ // placeholder so the menu wiring is uniform; always null until a real gate exists.
+ public string? QuickSessionDisabledReason => null;
+
+ public string? RefineDisabledReason
+ {
+ get
+ {
+ if (CanRefine) return null;
+ if (IsManual) return Loc.T("tasks.manualTip");
+ if (IsRefining) return Loc.T("tasks.reasonRefining");
+ if (PlanningPhase != PlanningPhase.None) return Loc.T("tasks.reasonIsPlanningParent");
+ return Loc.T("tasks.reasonNotIdle");
+ }
+ }
+
+ // Gates the "Planning" submenu header — null unless Open/Resume-or-Discard/Finalize would
+ // all be hidden, i.e. the submenu would otherwise open empty.
+ public string? PlanningDisabledReason
+ {
+ get
+ {
+ if (CanOpenPlanningSession || CanResumeOrDiscardPlanning || CanFinalizePlanning) return null;
+ if (IsManual) return Loc.T("tasks.manualTip");
+ if (IsChild) return Loc.T("tasks.reasonIsChildTask");
+ if (PlanningPhase == PlanningPhase.Finalized) return Loc.T("tasks.reasonPlanAlreadyFinalized");
+ return Loc.T("tasks.reasonNotIdle");
+ }
+ }
+
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).
@@ -243,6 +306,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(CanRemoveFromQueue));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanRefine));
+ OnPropertyChanged(nameof(SendToQueueDisabledReason));
+ OnPropertyChanged(nameof(CancelDisabledReason));
+ OnPropertyChanged(nameof(RefineDisabledReason));
+ OnPropertyChanged(nameof(PlanningDisabledReason));
}
partial void OnParentTaskIdChanged(string? value)
@@ -254,6 +321,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanOpenPlanningSession));
+ OnPropertyChanged(nameof(SendToQueueDisabledReason));
+ OnPropertyChanged(nameof(PlanningDisabledReason));
}
partial void OnParentInViewChanged(bool value)
@@ -270,6 +339,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(CanSendToQueue));
+ OnPropertyChanged(nameof(SendToQueueDisabledReason));
}
partial void OnPlanningPhaseChanged(PlanningPhase value)
@@ -285,9 +355,16 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanFinalizePlanning));
OnPropertyChanged(nameof(CanRefine));
+ OnPropertyChanged(nameof(SendToQueueDisabledReason));
+ OnPropertyChanged(nameof(RefineDisabledReason));
+ OnPropertyChanged(nameof(PlanningDisabledReason));
}
- partial void OnIsRefiningChanged(bool value) => OnPropertyChanged(nameof(CanRefine));
+ partial void OnIsRefiningChanged(bool value)
+ {
+ OnPropertyChanged(nameof(CanRefine));
+ OnPropertyChanged(nameof(RefineDisabledReason));
+ }
partial void OnIsManualChanged(bool value)
{
@@ -295,6 +372,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(CanRefine));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanOpenPlanningSession));
+ OnPropertyChanged(nameof(SendToQueueDisabledReason));
+ OnPropertyChanged(nameof(RefineDisabledReason));
+ OnPropertyChanged(nameof(PlanningDisabledReason));
}
partial void OnHasInteractiveSessionChanged(bool value)
@@ -305,6 +385,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowStatusChip));
OnPropertyChanged(nameof(StatusChipTooltip));
OnPropertyChanged(nameof(CanSendToQueue));
+ OnPropertyChanged(nameof(SendToQueueDisabledReason));
}
partial void OnFailureReasonChanged(string? value)
@@ -323,8 +404,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(CanRemoveFromQueue));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanQueuePlan));
+ OnPropertyChanged(nameof(SendToQueueDisabledReason));
}
+ partial void OnIsHoveredChanged(bool value) => OnPropertyChanged(nameof(ShowRowActions));
+ partial void OnIsSelectedChanged(bool value) => OnPropertyChanged(nameof(ShowRowActions));
+
partial void OnBlockedByTaskIdChanged(string? value)
{
OnPropertyChanged(nameof(IsQueued));
diff --git a/src/ClaudeDo.Ui/Views/Islands/Detail/TaskHeaderBar.axaml b/src/ClaudeDo.Ui/Views/Islands/Detail/TaskHeaderBar.axaml
index 6fcf4cbb..b37c21fe 100644
--- a/src/ClaudeDo.Ui/Views/Islands/Detail/TaskHeaderBar.axaml
+++ b/src/ClaudeDo.Ui/Views/Islands/Detail/TaskHeaderBar.axaml
@@ -25,6 +25,16 @@
TextWrapping="Wrap"
AcceptsReturn="False"
Padding="0"/>
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml
index 928b059e..df95483a 100644
--- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml
+++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml
@@ -41,7 +41,9 @@
Classes.dragging="{Binding IsDragging}"
Classes.drop-target="{Binding IsDropTarget}"
Classes.done="{Binding Done}"
- ContextRequested="OnRowContextRequested">
+ ContextRequested="OnRowContextRequested"
+ PointerEntered="OnRowPointerEntered"
+ PointerExited="OnRowPointerExited">
@@ -149,12 +151,18 @@
-
+
@@ -168,16 +176,6 @@
-
-
-
-
-
-
-
-
@@ -203,11 +201,16 @@
-
+
diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs
index a0af3960..cc733aa1 100644
--- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs
+++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs
@@ -21,8 +21,8 @@ public partial class TaskRowView : UserControl
this.GetVisualAncestors().OfType()
.Select(ic => ic.DataContext).OfType().FirstOrDefault();
- // The context menu is built here instead of declared in XAML so the ~18 MenuItems only
- // exist while a row's menu is actually open, not for every row rendered in the list.
+ // The context menu is built here instead of declared in XAML so the MenuItems only exist
+ // while a row's menu is actually open, not for every row rendered in the list.
private void OnRowContextRequested(object? sender, ContextRequestedEventArgs e)
{
if (sender is not Border border) return;
@@ -35,19 +35,36 @@ public partial class TaskRowView : UserControl
var menu = new ContextMenu { DataContext = row };
- MenuItem MakeItem(string key, EventHandler click, bool isVisible = true, string? tag = null)
+ // reason non-null => IsEnabled=false with that text as the tooltip (ToolTip.ShowOnDisabled
+ // renders it even though the item is disabled — verified headlessly on Avalonia 12, see
+ // task A3). Used for Group 1's four gated entries; submenu items keep isVisible hiding.
+ MenuItem MakeItem(string key, EventHandler click, bool isVisible = true, string? tag = null, string? reason = null)
{
var item = new MenuItem { Header = Loc.T(key), DataContext = row, IsVisible = isVisible };
if (tag is not null) item.Tag = tag;
+ if (reason is not null)
+ {
+ item.IsEnabled = false;
+ ToolTip.SetTip(item, reason);
+ ToolTip.SetShowOnDisabled(item, true);
+ }
item.Click += click;
return item;
}
- menu.Items.Add(MakeItem("tasks.ctxSendToQueue", OnSendToQueueClick, row.CanSendToQueue));
- menu.Items.Add(MakeItem("tasks.ctxRemoveFromQueue", OnRemoveFromQueueClick, row.CanRemoveFromQueue));
- menu.Items.Add(MakeItem("tasks.ctxCancelExecution", OnCancelExecutionClick, row.IsRunning));
+ // Group 1 — fixed length and order, always visible; an unmet precondition grays the
+ // entry out instead of hiding it (Remove from queue has no dedicated reason text, only
+ // the IsEnabled toggle).
+ menu.Items.Add(MakeItem("tasks.ctxSendToQueue", OnSendToQueueClick, reason: row.SendToQueueDisabledReason));
+ var removeFromQueueItem = MakeItem("tasks.ctxRemoveFromQueue", OnRemoveFromQueueClick);
+ removeFromQueueItem.IsEnabled = row.CanRemoveFromQueue;
+ menu.Items.Add(removeFromQueueItem);
+ menu.Items.Add(MakeItem("tasks.ctxCancelExecution", OnCancelExecutionClick, reason: row.CancelDisabledReason));
+ menu.Items.Add(MakeItem("tasks.ctxOpenConPtySession", OnOpenConPtySessionClick, reason: row.QuickSessionDisabledReason));
+ menu.Items.Add(MakeItem("tasks.ctxRefineTask", OnRefineTaskClick, reason: row.RefineDisabledReason));
menu.Items.Add(new Separator());
+ // "Mark as" — exclusive-choice hiding inside is unchanged; the header is never empty.
var markAs = new MenuItem { Header = Loc.T("tasks.ctxMarkAs"), DataContext = row };
markAs.Items.Add(MakeItem("tasks.ctxMarkDone", OnSetStatusClick, tag: "Done"));
markAs.Items.Add(MakeItem("tasks.ctxMarkCancelled", OnSetStatusClick, tag: "Cancelled"));
@@ -56,17 +73,29 @@ public partial class TaskRowView : UserControl
markAs.Items.Add(MakeItem("tasks.ctxMarkClaudeTask", OnToggleManualClick, row.IsManual));
menu.Items.Add(markAs);
- menu.Items.Add(new Separator());
- menu.Items.Add(MakeItem("tasks.ctxOpenConPtySession", OnOpenConPtySessionClick));
- menu.Items.Add(MakeItem("tasks.ctxOpenPlanningSession", OnOpenPlanningSessionClick, row.CanOpenPlanningSession));
- menu.Items.Add(MakeItem("tasks.ctxResumePlanningSession", OnResumePlanningSessionClick, row.CanResumeOrDiscardPlanning));
- menu.Items.Add(MakeItem("tasks.ctxFinalizePlanningSession", OnFinalizePlanningSessionClick, row.CanFinalizePlanning));
- menu.Items.Add(MakeItem("tasks.ctxDiscardPlanningSession", OnDiscardPlanningSessionClick, row.CanResumeOrDiscardPlanning));
- menu.Items.Add(new Separator());
- menu.Items.Add(MakeItem("tasks.ctxScheduleFor", OnScheduleForClick));
- menu.Items.Add(MakeItem("tasks.ctxClearSchedule", OnClearScheduleClick, row.HasSchedule));
- menu.Items.Add(MakeItem("tasks.ctxAddToMyDay", OnAddToMyDayClick, row.CanAddToMyDay));
- menu.Items.Add(MakeItem("tasks.ctxRemoveFromMyDay", OnRemoveFromMyDayClick, row.IsMyDay));
+ // "Planning" — the header itself disables + explains when it would otherwise open empty.
+ var planning = new MenuItem { Header = Loc.T("tasks.ctxPlanning"), DataContext = row };
+ if (row.PlanningDisabledReason is { } planningReason)
+ {
+ planning.IsEnabled = false;
+ ToolTip.SetTip(planning, planningReason);
+ ToolTip.SetShowOnDisabled(planning, true);
+ }
+ planning.Items.Add(MakeItem("tasks.ctxOpenPlanningSession", OnOpenPlanningSessionClick, row.CanOpenPlanningSession));
+ planning.Items.Add(MakeItem("tasks.ctxResumePlanningSession", OnResumePlanningSessionClick, row.CanResumeOrDiscardPlanning));
+ planning.Items.Add(MakeItem("tasks.ctxFinalizePlanningSession", OnFinalizePlanningSessionClick, row.CanFinalizePlanning));
+ planning.Items.Add(MakeItem("tasks.ctxDiscardPlanningSession", OnDiscardPlanningSessionClick, row.CanResumeOrDiscardPlanning));
+ menu.Items.Add(planning);
+
+ // "Schedule" — always has at least "Schedule for…", so the header is never empty.
+ var schedule = new MenuItem { Header = Loc.T("tasks.ctxSchedule"), DataContext = row };
+ schedule.Items.Add(MakeItem("tasks.ctxScheduleFor", OnScheduleForClick));
+ schedule.Items.Add(MakeItem("tasks.ctxClearSchedule", OnClearScheduleClick, row.HasSchedule));
+ schedule.Items.Add(new Separator());
+ schedule.Items.Add(MakeItem("tasks.ctxAddToMyDay", OnAddToMyDayClick, row.CanAddToMyDay));
+ schedule.Items.Add(MakeItem("tasks.ctxRemoveFromMyDay", OnRemoveFromMyDayClick, row.IsMyDay));
+ menu.Items.Add(schedule);
+
menu.Items.Add(new Separator());
menu.Items.Add(MakeItem("tasks.ctxDeleteTask", OnDeleteTaskClick));
@@ -92,6 +121,12 @@ public partial class TaskRowView : UserControl
await vm.CancelRunningTaskCommand.ExecuteAsync(row);
}
+ private async void OnRefineTaskClick(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
+ await vm.RefineTaskCommand.ExecuteAsync(row);
+ }
+
private async void OnClearScheduleClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
@@ -194,4 +229,14 @@ public partial class TaskRowView : UserControl
ScheduleAnchor.Flyout?.Hide();
_pendingScheduleRow = null;
}
+
+ private void OnRowPointerEntered(object? sender, PointerEventArgs e)
+ {
+ if (DataContext is TaskRowViewModel row) row.IsHovered = true;
+ }
+
+ private void OnRowPointerExited(object? sender, PointerEventArgs e)
+ {
+ if (DataContext is TaskRowViewModel row) row.IsHovered = false;
+ }
}
diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowContextMenuTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowContextMenuTests.cs
new file mode 100644
index 00000000..b191a8ad
--- /dev/null
+++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowContextMenuTests.cs
@@ -0,0 +1,162 @@
+using ClaudeDo.Data.Models;
+using ClaudeDo.Ui.ViewModels.Islands;
+using Xunit;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Ui.Tests.ViewModels;
+
+///
+/// [UX-Struktur A2/A4] Group 1's disabled-reason properties (grayed out with an explanation
+/// instead of hidden — see task A1) and the hover-or-selected gate for the optional row actions.
+///
+public class TaskRowContextMenuTests
+{
+ private static TaskRowViewModel MakeRow() => new() { Id = "t1" };
+
+ [Fact]
+ public void SendToQueueDisabledReason_Null_When_Sendable()
+ {
+ var row = MakeRow();
+ row.Status = TaskStatus.Idle;
+ Assert.True(row.CanSendToQueue);
+ Assert.Null(row.SendToQueueDisabledReason);
+ }
+
+ [Fact]
+ public void SendToQueueDisabledReason_Set_When_AlreadyQueued()
+ {
+ var row = MakeRow();
+ row.Status = TaskStatus.Queued;
+ Assert.False(row.CanSendToQueue);
+ Assert.NotNull(row.SendToQueueDisabledReason);
+ }
+
+ [Fact]
+ public void SendToQueueDisabledReason_Set_When_Manual()
+ {
+ var row = MakeRow();
+ row.Status = TaskStatus.Idle;
+ row.IsManual = true;
+ Assert.False(row.CanSendToQueue);
+ Assert.NotNull(row.SendToQueueDisabledReason);
+ }
+
+ [Fact]
+ public void CancelDisabledReason_Null_While_Running()
+ {
+ var row = MakeRow();
+ row.Status = TaskStatus.Running;
+ Assert.Null(row.CancelDisabledReason);
+ }
+
+ [Fact]
+ public void CancelDisabledReason_Set_When_NotRunning()
+ {
+ var row = MakeRow();
+ row.Status = TaskStatus.Idle;
+ Assert.NotNull(row.CancelDisabledReason);
+ }
+
+ [Theory]
+ [InlineData(TaskStatus.Idle)]
+ [InlineData(TaskStatus.Running)]
+ [InlineData(TaskStatus.Done)]
+ public void QuickSessionDisabledReason_Is_Always_Null(TaskStatus status)
+ {
+ var row = MakeRow();
+ row.Status = status;
+ row.IsManual = true;
+ Assert.Null(row.QuickSessionDisabledReason);
+ }
+
+ [Fact]
+ public void RefineDisabledReason_Null_When_Refinable()
+ {
+ var row = MakeRow();
+ row.Status = TaskStatus.Idle;
+ Assert.True(row.CanRefine);
+ Assert.Null(row.RefineDisabledReason);
+ }
+
+ [Fact]
+ public void RefineDisabledReason_Set_When_Manual()
+ {
+ var row = MakeRow();
+ row.Status = TaskStatus.Idle;
+ row.IsManual = true;
+ Assert.False(row.CanRefine);
+ Assert.NotNull(row.RefineDisabledReason);
+ }
+
+ [Fact]
+ public void RefineDisabledReason_Set_When_Refining()
+ {
+ var row = MakeRow();
+ row.Status = TaskStatus.Idle;
+ row.IsRefining = true;
+ Assert.False(row.CanRefine);
+ Assert.NotNull(row.RefineDisabledReason);
+ }
+
+ [Fact]
+ public void RefineDisabledReason_Set_When_NotIdle()
+ {
+ var row = MakeRow();
+ row.Status = TaskStatus.Running;
+ Assert.False(row.CanRefine);
+ Assert.NotNull(row.RefineDisabledReason);
+ }
+
+ [Fact]
+ public void PlanningDisabledReason_Null_When_OpenAvailable()
+ {
+ var row = MakeRow();
+ row.Status = TaskStatus.Idle;
+ Assert.True(row.CanOpenPlanningSession);
+ Assert.Null(row.PlanningDisabledReason);
+ }
+
+ [Fact]
+ public void PlanningDisabledReason_Null_When_PlanningActive()
+ {
+ var row = MakeRow();
+ row.Status = TaskStatus.Idle;
+ row.PlanningPhase = PlanningPhase.Active;
+ Assert.True(row.CanResumeOrDiscardPlanning);
+ Assert.Null(row.PlanningDisabledReason);
+ }
+
+ [Fact]
+ public void PlanningDisabledReason_Set_When_Finalized()
+ {
+ var row = MakeRow();
+ row.Status = TaskStatus.Idle;
+ row.PlanningPhase = PlanningPhase.Finalized;
+ Assert.False(row.CanOpenPlanningSession);
+ Assert.False(row.CanResumeOrDiscardPlanning);
+ Assert.False(row.CanFinalizePlanning);
+ Assert.NotNull(row.PlanningDisabledReason);
+ }
+
+ [Fact]
+ public void PlanningDisabledReason_Set_When_Manual()
+ {
+ var row = MakeRow();
+ row.Status = TaskStatus.Idle;
+ row.IsManual = true;
+ Assert.NotNull(row.PlanningDisabledReason);
+ }
+
+ [Theory]
+ [InlineData(false, false, false)]
+ [InlineData(true, false, true)]
+ [InlineData(false, true, true)]
+ [InlineData(true, true, true)]
+ public void ShowRowActions_Follows_Hover_Or_Selection(bool isHovered, bool isSelected, bool expected)
+ {
+ var row = MakeRow();
+ row.IsHovered = isHovered;
+ row.IsSelected = isSelected;
+ Assert.Equal(expected, row.ShowRowActions);
+ }
+}