feat(ui): Interactive chip for tasks with an open ConPTY session

A task driven by hand in Mission Control is Idle with an Active worktree, so its
lifecycle chip read "Parked" — indistinguishable from a task genuinely set aside.
The shell now mirrors Mission Control's open panes onto the rows, which show an
accent "Interactive" chip instead; tapping it surfaces Mission Control and
focuses that pane (the open command already dedupes by task id).
This commit is contained in:
Mika Kuns
2026-07-27 15:02:51 +02:00
parent 1466d0fbab
commit c93a20f20c
8 changed files with 92 additions and 9 deletions
@@ -35,6 +35,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
[ObservableProperty] private bool _parentInView = true;
[ObservableProperty] private int _roadblockCount;
[ObservableProperty] private bool _isRefining;
// 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;
@@ -84,8 +88,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
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;
// 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;
@@ -115,7 +121,14 @@ public sealed partial class TaskRowViewModel : ViewModelBase
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
// 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"),
@@ -130,10 +143,14 @@ public sealed partial class TaskRowViewModel : ViewModelBase
// 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.
public bool ShowStatusChip => !(PlanningPhase != PlanningPhase.None && Status == TaskStatus.Idle);
// 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 => (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch
public string StatusChipClass => HasInteractiveSession
? "interactive"
: (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch
{
(TaskStatus.Running, _) => "running",
(TaskStatus.WaitingForReview, _) => "review",
@@ -208,6 +225,15 @@ public sealed partial class TaskRowViewModel : ViewModelBase
partial void OnIsRefiningChanged(bool value) => OnPropertyChanged(nameof(CanRefine));
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));
@@ -21,6 +21,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private readonly Dictionary<string, bool> _expandedState = new();
private ListNavItemViewModel? _currentList;
private CancellationTokenSource? _loadCts;
// Task ids with a live ConPTY pane in Mission Control; kept here so rows loaded later still
// pick the flag up (see SyncInteractiveSessions).
private readonly HashSet<string> _interactiveSessionIds = new();
private static readonly TaskListFilterRegistry _filters = new();
public event EventHandler? SelectionChanged;
@@ -274,6 +277,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{
var row = TaskRowViewModel.FromEntity(t);
row.ShowListChip = showListChip;
row.HasInteractiveSession = _interactiveSessionIds.Contains(row.Id);
Items.Add(row);
}
@@ -441,6 +445,17 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
TasksChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>Replaces the set of tasks that currently have an open interactive (ConPTY) session,
/// so their lifecycle chip reads "Interactive" instead of "Parked".</summary>
public void SyncInteractiveSessions(IEnumerable<string> taskIds)
{
_interactiveSessionIds.Clear();
foreach (var id in taskIds)
_interactiveSessionIds.Add(id);
foreach (var r in Items)
r.HasInteractiveSession = _interactiveSessionIds.Contains(r.Id);
}
public bool CanReorder => _currentList?.Kind == ListKind.User;
public void ClearDropHints()