Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs
T

296 lines
14 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] private string _title = "";
[ObservableProperty] private string _listName = "";
[ObservableProperty] private bool _done;
[ObservableProperty] private bool _isStarred;
[ObservableProperty] private bool _isMyDay;
[ObservableProperty] private bool _isSelected;
[ObservableProperty] private TaskStatus _status;
[ObservableProperty] private PlanningPhase _planningPhase;
[ObservableProperty] private string? _branch;
[ObservableProperty] private string? _diffStat;
[ObservableProperty] private ClaudeDo.Data.Models.WorktreeState? _worktreeState;
[ObservableProperty] private DateTime? _scheduledFor;
[ObservableProperty] private int _diffAdditions;
[ObservableProperty] private int _diffDeletions;
[ObservableProperty] private bool _dropHintAbove;
[ObservableProperty] private bool _dropHintBelow;
[ObservableProperty] private string? _parentTaskId;
[ObservableProperty] private string? _createdBy;
[ObservableProperty] private string? _blockedByTaskId;
[ObservableProperty] private bool _isExpanded = true;
[ObservableProperty] private bool _hasPlanningChildren;
[ObservableProperty] private bool _hasQueuedSubtasks;
[ObservableProperty] private bool _showListChip = true;
[ObservableProperty] private bool _parentFinalized;
[ObservableProperty] private bool _parentInView = true;
[ObservableProperty] private int _roadblockCount;
[ObservableProperty] private bool _isRefining;
// Set by the custom drag while this row is being dragged — drives the "grabbed" row style.
[ObservableProperty] private bool _isDragging;
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None && !IsRefining;
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; }
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;
public bool CanResumeOrDiscardPlanning => PlanningPhase == PlanningPhase.Active;
// Pick up in a terminal only where a session + worktree reliably still exist: a task
// parked for review, or one that failed mid-run. The worker validates and reports a
// clear error if there's no resumable session/worktree.
public bool CanPickUpInTerminal => Status is TaskStatus.WaitingForReview or TaskStatus.Failed;
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.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).
public bool IsParked => Status == TaskStatus.Idle && WorktreeState == ClaudeDo.Data.Models.WorktreeState.Active;
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
public bool CanRemoveFromQueue => IsQueued || 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.
public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks
&& (!IsChild || ParentFinalized)
&& PlanningPhase != PlanningPhase.Active;
// 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;
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";
public string DiffAdditionsText => $"+{DiffAdditions}";
public string DiffDeletionsText => $"{DiffDeletions}";
public string StepsText => Loc.T("vm.taskRow.stepsText", StepsCompleted, StepsCount);
public string StatusLabel => IsParked ? Loc.T("vm.taskStatus.parked") : 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(),
};
public string StatusChipClass => (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) 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",
};
partial void OnStatusChanged(TaskStatus value)
{
OnPropertyChanged(nameof(StatusChipClass));
OnPropertyChanged(nameof(StatusLabel));
OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsWaitingForReview));
OnPropertyChanged(nameof(CanPickUpInTerminal));
OnPropertyChanged(nameof(IsParked));
OnPropertyChanged(nameof(IsQueued));
OnPropertyChanged(nameof(IsWaiting));
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(CanOpenPlanningSession));
OnPropertyChanged(nameof(CanRemoveFromQueue));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanRefine));
}
partial void OnParentTaskIdChanged(string? value)
{
OnPropertyChanged(nameof(IsChild));
OnPropertyChanged(nameof(IsAgentSuggested));
OnPropertyChanged(nameof(ShowAsChild));
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanOpenPlanningSession));
}
partial void OnParentInViewChanged(bool value)
{
OnPropertyChanged(nameof(ShowAsChild));
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
}
partial void OnCreatedByChanged(string? value) => OnPropertyChanged(nameof(IsAgentSuggested));
partial void OnParentFinalizedChanged(bool value)
{
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(CanSendToQueue));
}
partial void OnPlanningPhaseChanged(PlanningPhase value)
{
OnPropertyChanged(nameof(IsPlanningParent));
OnPropertyChanged(nameof(PlanningBadge));
OnPropertyChanged(nameof(IsPlanActive));
OnPropertyChanged(nameof(IsPlanFinalized));
OnPropertyChanged(nameof(CanOpenPlanningSession));
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
OnPropertyChanged(nameof(CanQueuePlan));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanFinalizePlanning));
OnPropertyChanged(nameof(CanRefine));
}
partial void OnIsRefiningChanged(bool value) => OnPropertyChanged(nameof(CanRefine));
partial void OnHasQueuedSubtasksChanged(bool value)
{
OnPropertyChanged(nameof(CanRemoveFromQueue));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanQueuePlan));
}
partial void OnBlockedByTaskIdChanged(string? value)
{
OnPropertyChanged(nameof(IsQueued));
OnPropertyChanged(nameof(IsWaiting));
OnPropertyChanged(nameof(StatusChipClass));
}
partial void OnHasPlanningChildrenChanged(bool value)
{
OnPropertyChanged(nameof(IsPlanningParent));
OnPropertyChanged(nameof(CanQueuePlan));
}
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
partial void OnWorktreeStateChanged(ClaudeDo.Data.Models.WorktreeState? value)
{
OnPropertyChanged(nameof(IsParked));
OnPropertyChanged(nameof(StatusLabel));
}
partial void OnDoneChanged(bool value)
{
OnPropertyChanged(nameof(IsOverdue));
OnPropertyChanged(nameof(CanAddToMyDay));
}
partial void OnIsMyDayChanged(bool value) => OnPropertyChanged(nameof(CanAddToMyDay));
partial void OnScheduledForChanged(DateTime? value)
{
OnPropertyChanged(nameof(IsOverdue));
OnPropertyChanged(nameof(HasSchedule));
}
partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffAdditionsText)); }
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); }
partial void OnRoadblockCountChanged(int value) { OnPropertyChanged(nameof(HasRoadblock)); OnPropertyChanged(nameof(RoadblockTooltip)); }
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)
{
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat);
Title = t.Title;
ListName = t.List?.Name ?? "";
Done = t.Status == TaskStatus.Done;
IsStarred = t.IsStarred;
IsMyDay = t.IsMyDay;
Status = t.Status;
PlanningPhase = t.PlanningPhase;
Branch = t.Worktree?.BranchName;
DiffStat = t.Worktree?.DiffStat;
WorktreeState = t.Worktree?.State;
ScheduledFor = t.ScheduledFor;
DiffAdditions = add;
DiffDeletions = del;
ParentTaskId = t.ParentTaskId;
CreatedBy = t.CreatedBy;
BlockedByTaskId = t.BlockedByTaskId;
RoadblockCount = t.RoadblockCount;
}
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions".
private static (int add, int del) ParseDiffStat(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return (0, 0);
int add = 0, del = 0;
var parts = s.Split(new[] { ' ', ',', '\t' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var p in parts)
{
if (p.Length > 1 && p[0] == '+' && int.TryParse(p.AsSpan(1), out var a)) add = a;
else if (p.Length > 1 && (p[0] == '-' || p[0] == '\u2212') && int.TryParse(p.AsSpan(1), out var d)) del = d;
}
return (add, del);
}
}