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; // 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] 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] private bool _hasInteractiveSession; // 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 && !IsManual; 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; } 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.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); 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 && !IsManual; // 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); // Null on a plain status chip so no tooltip shows there — the chip is only actionable // while a session is open. public string? InteractiveChipTooltip => HasInteractiveSession ? Loc.T("tasks.interactiveChipTip") : null; public string StatusLabel => HasInteractiveSession ? Loc.T("vm.taskStatus.interactive") : 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(), }; // 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)) 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(ShowStatusChip)); OnPropertyChanged(nameof(IsRunning)); OnPropertyChanged(nameof(IsWaitingForReview)); 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(ShowStatusChip)); 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 OnIsManualChanged(bool value) { OnPropertyChanged(nameof(ManualBadge)); OnPropertyChanged(nameof(CanRefine)); OnPropertyChanged(nameof(CanSendToQueue)); OnPropertyChanged(nameof(CanOpenPlanningSession)); } partial void OnHasInteractiveSessionChanged(bool value) { OnPropertyChanged(nameof(IsParked)); OnPropertyChanged(nameof(StatusChipClass)); OnPropertyChanged(nameof(StatusLabel)); OnPropertyChanged(nameof(ShowStatusChip)); OnPropertyChanged(nameof(InteractiveChipTooltip)); } 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; IsManual = t.IsManual; 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); } }