A task-based ConPTY session leaves the row Idle in the DB (sessions never touch status), so nothing stopped the queue picker from claiming it too: CanSendToQueue ignored HasInteractiveSession, and both TasksIslandViewModel. SendToQueueAsync and MissionControlViewModel.EnqueueTaskAsync (drag-to-queue) wrote Status=Queued straight via EF, bypassing TaskStateService entirely and its manual/draft-child guards. That let an autonomous claude process spawn in the same worktree a user was hand-editing in the ConPTY pane. Add !HasInteractiveSession to CanSendToQueue, and route both UI enqueue paths through IWorkerClient.SetTaskStatusAsync (worker hub -> TaskStateService. EnqueueAsync) instead of raw EF writes. The interactive-session gate itself stays in the UI: the worker has no notion of a UI-hosted ConPTY pane.
345 lines
16 KiB
C#
345 lines
16 KiB
C#
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. 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 => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks
|
||
&& (!IsChild || ParentFinalized)
|
||
&& PlanningPhase != PlanningPhase.Active
|
||
&& !IsManual
|
||
&& !HasInteractiveSession;
|
||
// 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));
|
||
OnPropertyChanged(nameof(CanSendToQueue));
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|