Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs
T
mika kuns 29ea18c427 fix(ui): make diff-stat parsing locale-safe and dedupe the two parsers
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.
2026-09-10 13:50:35 +02:00

533 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}