diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index 38a209f4..58564405 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -78,9 +78,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase // 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; - public string? ManualBadge => IsManual ? Loc.T("tasks.badgeManual") : null; public DateTime CreatedAt { get; init; } @@ -140,11 +137,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase // touches task status), so this gate has to live here: queueing a task the user is actively // hand-editing in an interactive pane would spawn an autonomous run racing it in the same // worktree. - public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks - && (!IsChild || ParentFinalized) - && PlanningPhase != PlanningPhase.Active - && !IsManual - && !HasInteractiveSession; + public bool CanSendToQueue => SendToQueueGate().Can; // 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 @@ -155,26 +148,28 @@ public sealed partial class TaskRowViewModel : ViewModelBase // 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 + // verified headlessly to actually render on Avalonia 12 — see task A3). Each pair (CanX, + // XDisabledReason) is derived from one private "gate" method so there is a single source of + // truth for the condition instead of a CanX expression and a hand-written negation of it. + private (bool Can, string? Reason) SendToQueueGate() { - 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; - } + if (IsRunning) return (false, Loc.T("tasks.reasonAlreadyRunning")); + if (IsQueued) return (false, Loc.T("tasks.reasonAlreadyQueued")); + if (IsWaitingForReview) return (false, Loc.T("tasks.reasonWaitingForReview")); + if (HasQueuedSubtasks) return (false, Loc.T("tasks.reasonSubtasksQueued")); + if (IsChild && !ParentFinalized) return (false, Loc.T("tasks.reasonPlanNotFinalized")); + if (PlanningPhase == PlanningPhase.Active) return (false, Loc.T("tasks.reasonPlanningActive")); + if (IsManual) return (false, Loc.T("tasks.manualTip")); + if (HasInteractiveSession) return (false, Loc.T("tasks.reasonInteractiveSession")); + return (true, null); } + public string? SendToQueueDisabledReason => SendToQueueGate().Reason; - public string? CancelDisabledReason => IsRunning ? null : Loc.T("tasks.reasonNotRunning"); + // IsRunning is a standalone status property used well beyond this one gate, so it stays as + // is; only the reason text is derived from a gate method for consistency with the others. + private (bool Can, string? Reason) CancelGate() => + IsRunning ? (true, null) : (false, Loc.T("tasks.reasonNotRunning")); + public string? CancelDisabledReason => CancelGate().Reason; // "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. @@ -182,36 +177,40 @@ public sealed partial class TaskRowViewModel : ViewModelBase // The row keeps a merged/discarded worktree's recorded Path, so a non-empty string is not // proof the folder is still there — same on-disk check the worktrees overview and the merge - // section do before offering "open". Only read when the context menu is built, not per row. - public bool CanOpenWorktree => - !string.IsNullOrWhiteSpace(WorktreePath) && System.IO.Directory.Exists(WorktreePath); - public string? OpenWorktreeDisabledReason => CanOpenWorktree ? null : Loc.T("tasks.reasonNoWorktree"); - - public string? RefineDisabledReason + // section do before offering "open". Only read when the context menu is built, not per row; + // each call to the gate does exactly one Directory.Exists (no caching). + private (bool Can, string? Reason) OpenWorktreeGate() { - 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"); - } + var can = !string.IsNullOrWhiteSpace(WorktreePath) && System.IO.Directory.Exists(WorktreePath); + return (can, can ? null : Loc.T("tasks.reasonNoWorktree")); } + public bool CanOpenWorktree => OpenWorktreeGate().Can; + public string? OpenWorktreeDisabledReason => OpenWorktreeGate().Reason; + + private (bool Can, string? Reason) RefineGate() + { + if (IsManual) return (false, Loc.T("tasks.manualTip")); + if (IsRefining) return (false, Loc.T("tasks.reasonRefining")); + if (PlanningPhase != PlanningPhase.None) return (false, Loc.T("tasks.reasonIsPlanningParent")); + if (Status != TaskStatus.Idle) return (false, Loc.T("tasks.reasonNotIdle")); + return (true, null); + } + public bool CanRefine => RefineGate().Can; + public string? RefineDisabledReason => RefineGate().Reason; // 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 + // all be hidden, i.e. the submenu would otherwise open empty. The three CanX below stay + // standalone properties (bindings/tests use them individually); the gate only derives the + // combined reason. + private (bool Can, string? Reason) PlanningGate() { - 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"); - } + if (CanOpenPlanningSession || CanResumeOrDiscardPlanning || CanFinalizePlanning) return (true, null); + if (IsManual) return (false, Loc.T("tasks.manualTip")); + if (IsChild) return (false, Loc.T("tasks.reasonIsChildTask")); + if (PlanningPhase == PlanningPhase.Finalized) return (false, Loc.T("tasks.reasonPlanAlreadyFinalized")); + return (false, Loc.T("tasks.reasonNotIdle")); } + public string? PlanningDisabledReason => PlanningGate().Reason; 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 diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowContextMenuTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowContextMenuTests.cs index b191a8ad..1df432fd 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowContextMenuTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowContextMenuTests.cs @@ -159,4 +159,76 @@ public class TaskRowContextMenuTests row.IsSelected = isSelected; Assert.Equal(expected, row.ShowRowActions); } + + // Pins down what the gate methods guarantee by construction: each of the five CanX/reason + // pairs must agree on every reachable state, not just the handful of examples above. Swept + // across the full state matrix (every TaskStatus x PlanningPhase x IsChild/ParentFinalized x + // IsManual x HasInteractiveSession x HasQueuedSubtasks x IsRefining) rather than one example + // per gate, so a future edit to a gate's condition without touching its Reason branch (or + // vice versa) fails loudly instead of silently drifting. + public static IEnumerable GateStateMatrix() + { + foreach (var status in Enum.GetValues()) + foreach (var phase in Enum.GetValues()) + foreach (var isChild in Bools) + foreach (var parentFinalized in Bools) + foreach (var isManual in Bools) + foreach (var hasInteractiveSession in Bools) + foreach (var hasQueuedSubtasks in Bools) + foreach (var isRefining in Bools) + yield return new object[] + { + status, phase, isChild, parentFinalized, isManual, + hasInteractiveSession, hasQueuedSubtasks, isRefining, + }; + } + + private static readonly bool[] Bools = { false, true }; + + [Theory] + [MemberData(nameof(GateStateMatrix))] + public void CanX_Agrees_With_DisabledReason_Across_State_Matrix( + TaskStatus status, PlanningPhase phase, bool isChild, bool parentFinalized, bool isManual, + bool hasInteractiveSession, bool hasQueuedSubtasks, bool isRefining) + { + var row = new TaskRowViewModel + { + Id = "t1", + Status = status, + PlanningPhase = phase, + ParentTaskId = isChild ? "parent-id" : null, + ParentFinalized = parentFinalized, + IsManual = isManual, + HasInteractiveSession = hasInteractiveSession, + HasQueuedSubtasks = hasQueuedSubtasks, + IsRefining = isRefining, + }; + + Assert.Equal(row.CanSendToQueue, row.SendToQueueDisabledReason is null); + Assert.Equal(row.IsRunning, row.CancelDisabledReason is null); + Assert.Equal(row.CanRefine, row.RefineDisabledReason is null); + + var canOpenPlanningMenu = row.CanOpenPlanningSession || row.CanResumeOrDiscardPlanning || row.CanFinalizePlanning; + Assert.Equal(canOpenPlanningMenu, row.PlanningDisabledReason is null); + } + + [Fact] + public void OpenWorktreeDisabledReason_Agrees_With_CanOpenWorktree_When_NoPath() + { + var row = MakeRow(); + Assert.Equal(row.CanOpenWorktree, row.OpenWorktreeDisabledReason is null); + } + + [Fact] + public void OpenWorktreeDisabledReason_Agrees_With_CanOpenWorktree_When_Path_Exists() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var row = MakeRow(); + row.WorktreePath = dir.FullName; + Assert.Equal(row.CanOpenWorktree, row.OpenWorktreeDisabledReason is null); + } + finally { dir.Delete(true); } + } }