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 _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; } }