Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs
T
mika kuns 87de53e052 refactor(ui): remove pick-up-in-terminal, keep ConPTY as the single session entry
Two context-menu entries opened a Claude session for the same task via different
mechanisms (embedded ConPTY vs. an external wt terminal). Drop the external-terminal
path entirely, including its worker hub method, launcher plumbing, and localization
keys, since the embedded ConPTY session already covers every case it did.
2026-08-05 09:05:46 +02:00

340 lines
16 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;
// 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);
}
}