TaskRowViewModel had its own ParseDiffStat expecting "+12"/"-3" tokens that
`git diff --stat` never emits, so the row badge always showed 0/0. Meanwhile
DetailsIslandViewModel's parser matched only the English words
"insertion"/"deletion", so it broke on a localized git (e.g. German Windows:
"Einfügung(en)"/"Löschung(en)").
Row and detail badge now share one parser, anchored on git's untranslated
(+)/(-) suffix instead of the language-dependent word before it, so it reads
correctly in any locale. Also corrected the Worker CLAUDE.md's branch-name
claim (`claudedo/{taskId[:8]}`) to match the actual full-GUID branch name.
533 lines
28 KiB
C#
533 lines
28 KiB
C#
using CommunityToolkit.Mvvm.ComponentModel;
|
||
using ClaudeDo.Data.Models;
|
||
using ClaudeDo.Ui.Localization;
|
||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||
|
||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||
|
||
public sealed partial class TaskRowViewModel : ViewModelBase
|
||
{
|
||
public required string Id { get; init; }
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(ShowNumberBadge))]
|
||
private int _number;
|
||
[ObservableProperty] private string _title = "";
|
||
[ObservableProperty] private string _listName = "";
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(IsOverdue))]
|
||
[NotifyPropertyChangedFor(nameof(CanAddToMyDay))]
|
||
private bool _done;
|
||
[ObservableProperty] private bool _isStarred;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(CanAddToMyDay))]
|
||
private bool _isMyDay;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(ShowRowActions))]
|
||
private bool _isSelected;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
|
||
[NotifyPropertyChangedFor(nameof(StatusLabel))]
|
||
[NotifyPropertyChangedFor(nameof(ShowStatusChip))]
|
||
[NotifyPropertyChangedFor(nameof(IsRunning))]
|
||
[NotifyPropertyChangedFor(nameof(IsWaitingForReview))]
|
||
[NotifyPropertyChangedFor(nameof(IsParked))]
|
||
[NotifyPropertyChangedFor(nameof(IsQueued))]
|
||
[NotifyPropertyChangedFor(nameof(IsWaiting))]
|
||
[NotifyPropertyChangedFor(nameof(HasFailureReason))]
|
||
[NotifyPropertyChangedFor(nameof(FailureReasonTooltip))]
|
||
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
|
||
[NotifyPropertyChangedFor(nameof(IsDraft))]
|
||
[NotifyPropertyChangedFor(nameof(IsPlanned))]
|
||
[NotifyPropertyChangedFor(nameof(CanOpenPlanningSession))]
|
||
[NotifyPropertyChangedFor(nameof(CanRemoveFromQueue))]
|
||
[NotifyPropertyChangedFor(nameof(CanSendToQueue))]
|
||
[NotifyPropertyChangedFor(nameof(CanRefine))]
|
||
[NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))]
|
||
[NotifyPropertyChangedFor(nameof(CancelDisabledReason))]
|
||
[NotifyPropertyChangedFor(nameof(RefineDisabledReason))]
|
||
[NotifyPropertyChangedFor(nameof(PlanningDisabledReason))]
|
||
[NotifyPropertyChangedFor(nameof(MarkAsDisabledReason))]
|
||
private TaskStatus _status;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(IsPlanningParent))]
|
||
[NotifyPropertyChangedFor(nameof(PlanningBadge))]
|
||
[NotifyPropertyChangedFor(nameof(ShowStatusChip))]
|
||
[NotifyPropertyChangedFor(nameof(IsPlanActive))]
|
||
[NotifyPropertyChangedFor(nameof(IsPlanFinalized))]
|
||
[NotifyPropertyChangedFor(nameof(CanOpenPlanningSession))]
|
||
[NotifyPropertyChangedFor(nameof(CanResumeOrDiscardPlanning))]
|
||
[NotifyPropertyChangedFor(nameof(CanQueuePlan))]
|
||
[NotifyPropertyChangedFor(nameof(CanSendToQueue))]
|
||
[NotifyPropertyChangedFor(nameof(CanFinalizePlanning))]
|
||
[NotifyPropertyChangedFor(nameof(CanRefine))]
|
||
[NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))]
|
||
[NotifyPropertyChangedFor(nameof(RefineDisabledReason))]
|
||
[NotifyPropertyChangedFor(nameof(PlanningDisabledReason))]
|
||
private PlanningPhase _planningPhase;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(HasBranch))]
|
||
private string? _branch;
|
||
[ObservableProperty] private string? _diffStat;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(IsParked))]
|
||
[NotifyPropertyChangedFor(nameof(StatusLabel))]
|
||
private ClaudeDo.Data.Models.WorktreeState? _worktreeState;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(CanOpenWorktree))]
|
||
[NotifyPropertyChangedFor(nameof(OpenWorktreeDisabledReason))]
|
||
private string? _worktreePath;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(IsOverdue))]
|
||
[NotifyPropertyChangedFor(nameof(HasSchedule))]
|
||
private DateTime? _scheduledFor;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(HasDiff))]
|
||
[NotifyPropertyChangedFor(nameof(DiffAdditionsText))]
|
||
private int _diffAdditions;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(HasDiff))]
|
||
[NotifyPropertyChangedFor(nameof(DiffDeletionsText))]
|
||
private int _diffDeletions;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(IsDropTarget))]
|
||
private bool _dropHintAbove;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(IsDropTarget))]
|
||
private bool _dropHintBelow;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(IsChild))]
|
||
[NotifyPropertyChangedFor(nameof(IsAgentSuggested))]
|
||
[NotifyPropertyChangedFor(nameof(ShowAsChild))]
|
||
[NotifyPropertyChangedFor(nameof(IsDraft))]
|
||
[NotifyPropertyChangedFor(nameof(IsPlanned))]
|
||
[NotifyPropertyChangedFor(nameof(CanSendToQueue))]
|
||
[NotifyPropertyChangedFor(nameof(CanOpenPlanningSession))]
|
||
[NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))]
|
||
[NotifyPropertyChangedFor(nameof(PlanningDisabledReason))]
|
||
private string? _parentTaskId;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(IsAgentSuggested))]
|
||
private string? _createdBy;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(IsQueued))]
|
||
[NotifyPropertyChangedFor(nameof(IsWaiting))]
|
||
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
|
||
[NotifyPropertyChangedFor(nameof(StatusLabel))]
|
||
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
|
||
[NotifyPropertyChangedFor(nameof(CanRemoveFromQueue))]
|
||
private string? _blockedByTaskId;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(DependsOnTaskId))]
|
||
private IReadOnlyList<string> _dependsOnTaskIds = [];
|
||
// Extrinsic — assigned by TasksIslandViewModel.ApplyDependencyWaitState, which alone has the
|
||
// cross-row graph (plus any cross-list predecessor lookup) needed to tell an unmet
|
||
// task_dependencies edge from a satisfied one. True only for the task_dependencies case;
|
||
// IsWaiting also ORs in the older BlockedByTaskId planning-chain link.
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(IsQueued))]
|
||
[NotifyPropertyChangedFor(nameof(IsWaiting))]
|
||
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
|
||
[NotifyPropertyChangedFor(nameof(StatusLabel))]
|
||
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
|
||
[NotifyPropertyChangedFor(nameof(CanRemoveFromQueue))]
|
||
private bool _isBlockedByDependency;
|
||
// The predecessor(s) this row is waiting on, rendered ("#42" or "#42, #57"), or null when not
|
||
// waiting. Prefers BlockedByTaskId's single predecessor (the planning chain "parent wins" the
|
||
// same way it does for the chain rail), else lists every unmet task_dependencies edge.
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
|
||
private string? _dependencyWaitLabel;
|
||
|
||
// The single predecessor, or null when the task has none or several. The chain rail can only
|
||
// draw a line, so a task waiting on n predecessors gets a ChainAfterLabel instead — see
|
||
// TasksIslandViewModel.ApplyChainGrouping.
|
||
public string? DependsOnTaskId => DependsOnTaskIds.Count == 1 ? DependsOnTaskIds[0] : null;
|
||
[ObservableProperty] private bool _isExpanded = true;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(IsPlanningParent))]
|
||
[NotifyPropertyChangedFor(nameof(CanQueuePlan))]
|
||
private bool _hasPlanningChildren;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(CanRemoveFromQueue))]
|
||
[NotifyPropertyChangedFor(nameof(CanSendToQueue))]
|
||
[NotifyPropertyChangedFor(nameof(CanQueuePlan))]
|
||
[NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))]
|
||
private bool _hasQueuedSubtasks;
|
||
[ObservableProperty] private bool _showListChip = true;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(IsDraft))]
|
||
[NotifyPropertyChangedFor(nameof(IsPlanned))]
|
||
[NotifyPropertyChangedFor(nameof(CanSendToQueue))]
|
||
[NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))]
|
||
private bool _parentFinalized;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(ShowAsChild))]
|
||
[NotifyPropertyChangedFor(nameof(IsDraft))]
|
||
[NotifyPropertyChangedFor(nameof(IsPlanned))]
|
||
private bool _parentInView = true;
|
||
// The three below are extrinsic — computed and assigned by TasksIslandViewModel.Regroup
|
||
// (it alone has the cross-row graph needed to walk DependsOnTaskId chains), mirroring how
|
||
// ParentInView is assigned by the same pass rather than derived locally.
|
||
[ObservableProperty] private bool _showAsChainMember;
|
||
[ObservableProperty] private int? _chainStep;
|
||
[ObservableProperty] private string? _chainAfterLabel;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(HasRoadblock))]
|
||
[NotifyPropertyChangedFor(nameof(RoadblockTooltip))]
|
||
private int _roadblockCount;
|
||
// Only meaningful when Status=Failed; stamped by TaskRunner.MarkFailed. "unknown" for a
|
||
// Failed task that predates this field. Null on every other status.
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(HasFailureReason))]
|
||
[NotifyPropertyChangedFor(nameof(FailureReasonLabel))]
|
||
[NotifyPropertyChangedFor(nameof(FailureReasonTooltip))]
|
||
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
|
||
private string? _failureReason;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(FailureReasonTooltip))]
|
||
private int? _failureTurnsUsed;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(FailureReasonTooltip))]
|
||
private int? _failureMaxTurns;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(CanRefine))]
|
||
[NotifyPropertyChangedFor(nameof(RefineDisabledReason))]
|
||
private bool _isRefining;
|
||
// Manual = a reminder only the user can do. Every "hand this to Claude" affordance is hidden
|
||
// and automation skips it; opening a hand-driven ConPTY session stays allowed.
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(ManualBadge))]
|
||
[NotifyPropertyChangedFor(nameof(CanRefine))]
|
||
[NotifyPropertyChangedFor(nameof(CanSendToQueue))]
|
||
[NotifyPropertyChangedFor(nameof(CanOpenPlanningSession))]
|
||
[NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))]
|
||
[NotifyPropertyChangedFor(nameof(RefineDisabledReason))]
|
||
[NotifyPropertyChangedFor(nameof(PlanningDisabledReason))]
|
||
private bool _isManual;
|
||
// Set by the shell from Mission Control's open ConPTY panes: this task has a live hand-driven
|
||
// session, which outranks the persisted status on the lifecycle chip (an interactive task is
|
||
// typically Idle+Active-worktree, i.e. would otherwise read "Parked").
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(IsParked))]
|
||
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
|
||
[NotifyPropertyChangedFor(nameof(StatusLabel))]
|
||
[NotifyPropertyChangedFor(nameof(ShowStatusChip))]
|
||
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
|
||
[NotifyPropertyChangedFor(nameof(CanSendToQueue))]
|
||
[NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))]
|
||
[NotifyPropertyChangedFor(nameof(CanDeleteTask))]
|
||
[NotifyPropertyChangedFor(nameof(DeleteDisabledReason))]
|
||
private bool _hasInteractiveSession;
|
||
// 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]
|
||
[NotifyPropertyChangedFor(nameof(ShowRowActions))]
|
||
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
|
||
// nothing left to show past that point.
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(HasCreationPhase))]
|
||
[NotifyPropertyChangedFor(nameof(CreationPhaseLabel))]
|
||
private string? _creationPhase;
|
||
|
||
// True while a drag is hovering this row (i.e. it would show a drop-hint gap). Used to
|
||
// 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 string? ManualBadge => IsManual ? Loc.T("tasks.badgeManual") : null;
|
||
|
||
public DateTime CreatedAt { get; init; }
|
||
public string CreatedAtFormatted => CreatedAt == default ? "—" : Loc.T("vm.taskRow.createdPrefix", CreatedAt.ToString("MMM d"));
|
||
|
||
public int StepsCount { get; init; }
|
||
public int StepsCompleted { get; init; }
|
||
|
||
// Number is 0 for rows created outside TaskNumberAllocator (test seeds, a future import
|
||
// path); a bare "#0" would be meaningless, so hide the badge entirely below 1.
|
||
public bool ShowNumberBadge => Number > 0;
|
||
|
||
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
|
||
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 => ShowAsChild && Status == TaskStatus.Idle && !ParentFinalized;
|
||
public bool IsPlanned => ShowAsChild && Status == TaskStatus.Idle && ParentFinalized;
|
||
|
||
public bool CanOpenPlanningSession => Status == TaskStatus.Idle
|
||
&& PlanningPhase == PlanningPhase.None
|
||
&& !IsChild
|
||
&& !IsManual;
|
||
public bool CanResumeOrDiscardPlanning => PlanningPhase == PlanningPhase.Active;
|
||
|
||
public string? PlanningBadge => PlanningPhase switch
|
||
{
|
||
PlanningPhase.Active => Loc.T("vm.planningBadge.active"),
|
||
PlanningPhase.Finalized => Loc.T("vm.planningBadge.finalized"),
|
||
_ => null,
|
||
};
|
||
|
||
public bool IsPlanActive => PlanningPhase == PlanningPhase.Active;
|
||
public bool IsPlanFinalized => PlanningPhase == PlanningPhase.Finalized;
|
||
|
||
public bool HasBranch => !string.IsNullOrWhiteSpace(Branch);
|
||
public bool HasDiff => DiffAdditions > 0 || DiffDeletions > 0;
|
||
public bool HasSteps => StepsCount > 0;
|
||
public bool IsOverdue => ScheduledFor is { } d && d.ToLocalTime().Date < DateTime.Today && !Done;
|
||
public bool IsRunning => Status == TaskStatus.Running;
|
||
public bool IsWaitingForReview => Status == TaskStatus.WaitingForReview;
|
||
// Parked = set aside from review: Idle but still holding its Active worktree (vs a plain Idle
|
||
// task). A live ConPTY session is that same shape but not parked — it reads "Interactive".
|
||
public bool IsParked => Status == TaskStatus.Idle && WorktreeState == ClaudeDo.Data.Models.WorktreeState.Active
|
||
&& !HasInteractiveSession;
|
||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId) && !IsBlockedByDependency;
|
||
public bool IsWaiting => Status == TaskStatus.Queued && (!string.IsNullOrEmpty(BlockedByTaskId) || IsBlockedByDependency);
|
||
public bool CanRemoveFromQueue => IsQueued || IsWaiting || 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. The worker never sees a UI-hosted ConPTY session (it never
|
||
// 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 => 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
|
||
&& PlanningPhase == PlanningPhase.Finalized
|
||
&& !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 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()
|
||
{
|
||
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;
|
||
|
||
// 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;
|
||
|
||
// Gates "Mark Done"/"Mark Cancelled" in the context menu — forcing either status while the
|
||
// task is actively running would strand the CLI process untracked (WorkerHub.SetTaskStatus
|
||
// rejects it server-side too; this is the UI-visible half of that guard).
|
||
private (bool Can, string? Reason) MarkAsGate() =>
|
||
IsRunning ? (false, Loc.T("tasks.reasonAlreadyRunning")) : (true, null);
|
||
public string? MarkAsDisabledReason => MarkAsGate().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.
|
||
public string? QuickSessionDisabledReason => null;
|
||
|
||
// 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;
|
||
// each call to the gate does exactly one Directory.Exists (no caching).
|
||
private (bool Can, string? Reason) OpenWorktreeGate()
|
||
{
|
||
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. 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()
|
||
{
|
||
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;
|
||
|
||
// Deleting out from under a live ConPTY session would orphan the claude process in Mission
|
||
// Control (it never gets a task-deleted signal) — same rationale as SendToQueueGate.
|
||
private (bool Can, string? Reason) DeleteGate() =>
|
||
HasInteractiveSession ? (false, Loc.T("tasks.reasonInteractiveSession")) : (true, null);
|
||
public bool CanDeleteTask => DeleteGate().Can;
|
||
public string? DeleteDisabledReason => DeleteGate().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
|
||
// 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"
|
||
: $"{RoadblockCount} roadblocks reported during the run — see details";
|
||
|
||
/// Mirrors WorktreeManager.PhaseCreatingWorktree — a hub payload token, not a display string.
|
||
private const string PhaseCreatingWorktree = "creating_worktree";
|
||
|
||
public bool HasCreationPhase => CreationPhase is not null;
|
||
public string? CreationPhaseLabel => CreationPhase switch
|
||
{
|
||
PhaseCreatingWorktree => Loc.T("ops.worker.creatingWorktree"),
|
||
_ => null,
|
||
};
|
||
|
||
// True for every Failed task, even one that predates this field — FailureReasonLabel then
|
||
// falls back to "unknown" instead of leaving the tooltip blank.
|
||
public bool HasFailureReason => Status == TaskStatus.Failed;
|
||
public string FailureReasonLabel => FailureReason switch
|
||
{
|
||
"max_turns" => Loc.T("vm.failureReason.maxTurns"),
|
||
"timeout" => Loc.T("vm.failureReason.timeout"),
|
||
"usage_limit" => Loc.T("vm.failureReason.usageLimit"),
|
||
"cancelled" => Loc.T("vm.failureReason.cancelled"),
|
||
"error" => Loc.T("vm.failureReason.error"),
|
||
_ => Loc.T("vm.failureReason.unknown"),
|
||
};
|
||
// max_turns gets the actionable detail (turns used/configured) since that's the one case
|
||
// where the fix is "raise maxTurns and continue_task", not "reset and re-run".
|
||
public string? FailureReasonTooltip => !HasFailureReason ? null
|
||
: FailureReason == "max_turns" && FailureTurnsUsed is { } used && FailureMaxTurns is { } max
|
||
? Loc.T("vm.failureReasonTooltip.maxTurns", used, max)
|
||
: FailureReasonLabel;
|
||
|
||
// Drives the status chip tooltip: an open interactive session takes priority (it's tappable),
|
||
// then the predecessor(s) a waiting task is blocked on, then a Failed task's reason,
|
||
// otherwise no tooltip.
|
||
public string? StatusChipTooltip
|
||
=> HasInteractiveSession ? Loc.T("tasks.interactiveChipTip")
|
||
: IsWaiting && DependencyWaitLabel is not null ? Loc.T("tasks.waitingOnTip", DependencyWaitLabel)
|
||
: FailureReasonTooltip;
|
||
|
||
public string DiffAdditionsText => $"+{DiffAdditions}";
|
||
public string DiffDeletionsText => $"−{DiffDeletions}";
|
||
public string StepsText => Loc.T("vm.taskRow.stepsText", StepsCompleted, StepsCount);
|
||
|
||
public string StatusLabel
|
||
=> HasInteractiveSession ? Loc.T("vm.taskStatus.interactive")
|
||
: IsParked ? Loc.T("vm.taskStatus.parked")
|
||
: IsWaiting ? Loc.T("vm.taskStatus.waiting") : Status switch
|
||
{
|
||
TaskStatus.Idle => Loc.T("vm.taskStatus.idle"),
|
||
TaskStatus.Queued => Loc.T("vm.taskStatus.queued"),
|
||
TaskStatus.Running => Loc.T("vm.taskStatus.running"),
|
||
TaskStatus.WaitingForReview => Loc.T("vm.taskStatus.waitingForReview"),
|
||
TaskStatus.WaitingForChildren => Loc.T("vm.taskStatus.waitingForChildren"),
|
||
TaskStatus.Done => Loc.T("vm.taskStatus.done"),
|
||
TaskStatus.Failed => Loc.T("vm.taskStatus.failed"),
|
||
TaskStatus.Cancelled => Loc.T("vm.taskStatus.cancelled"),
|
||
_ => Status.ToString(),
|
||
};
|
||
|
||
// A planning parent that is still Idle (Active planning, or Finalized-but-not-yet-queued)
|
||
// already shows its state via the PLANNING/PLANNED badge next to the title. The lifecycle
|
||
// chip would read "Idle" (or "Parked"), which is misleading, so hide it in that case —
|
||
// unless there's a live session, where "Interactive" is exactly what the user needs to see.
|
||
public bool ShowStatusChip => HasInteractiveSession
|
||
|| !(PlanningPhase != PlanningPhase.None && Status == TaskStatus.Idle);
|
||
|
||
public string StatusChipClass => HasInteractiveSession
|
||
? "interactive"
|
||
: (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId) || IsBlockedByDependency) switch
|
||
{
|
||
(TaskStatus.Running, _) => "running",
|
||
(TaskStatus.WaitingForReview, _) => "review",
|
||
(TaskStatus.WaitingForChildren, _) => "children",
|
||
(TaskStatus.Failed, _) => "error",
|
||
(TaskStatus.Done, _) => "done",
|
||
(TaskStatus.Queued, true) => "waiting",
|
||
(TaskStatus.Queued, false) => "queued",
|
||
_ => "idle",
|
||
};
|
||
|
||
public void RefreshLocalized()
|
||
{
|
||
OnPropertyChanged(nameof(StatusLabel));
|
||
OnPropertyChanged(nameof(PlanningBadge));
|
||
OnPropertyChanged(nameof(CreatedAtFormatted));
|
||
OnPropertyChanged(nameof(StepsText));
|
||
}
|
||
|
||
public static TaskRowViewModel FromEntity(TaskEntity t)
|
||
{
|
||
var row = new TaskRowViewModel { Id = t.Id, CreatedAt = t.CreatedAt };
|
||
row.UpdateFromEntity(t);
|
||
return row;
|
||
}
|
||
|
||
public void UpdateFromEntity(TaskEntity t)
|
||
{
|
||
// Any entity-backed refresh reflects a settled state, so the transient creation-phase
|
||
// banner (set from the OperationProgress broadcast) has nothing left to announce.
|
||
CreationPhase = null;
|
||
var (add, del) = DetailsIslandViewModel.ParseDiffStat(t.Worktree?.DiffStat);
|
||
Number = t.Number;
|
||
Title = t.Title;
|
||
ListName = t.List?.Name ?? "";
|
||
Done = t.Status == TaskStatus.Done;
|
||
IsStarred = t.IsStarred;
|
||
IsMyDay = t.IsMyDay;
|
||
IsManual = t.IsManual;
|
||
Status = t.Status;
|
||
PlanningPhase = t.PlanningPhase;
|
||
Branch = t.Worktree?.BranchName;
|
||
DiffStat = t.Worktree?.DiffStat;
|
||
WorktreeState = t.Worktree?.State;
|
||
WorktreePath = t.Worktree?.Path;
|
||
ScheduledFor = t.ScheduledFor;
|
||
DiffAdditions = add;
|
||
DiffDeletions = del;
|
||
ParentTaskId = t.ParentTaskId;
|
||
CreatedBy = t.CreatedBy;
|
||
BlockedByTaskId = t.BlockedByTaskId;
|
||
// Guarded: TaskEntity.DependsOnTaskIds projects a fresh List every read, so a plain
|
||
// assignment would fire PropertyChanged on every reconcile tick even when nothing moved.
|
||
var deps = t.DependsOnTaskIds;
|
||
if (!DependsOnTaskIds.SequenceEqual(deps)) DependsOnTaskIds = deps;
|
||
RoadblockCount = t.RoadblockCount;
|
||
FailureReason = t.FailureReason;
|
||
FailureTurnsUsed = t.FailureTurnsUsed;
|
||
FailureMaxTurns = t.FailureMaxTurns;
|
||
}
|
||
}
|