fix(ui): block delete on interactive-session tasks, clear ghost selection
Delete (row context menu + detail header) now gates on HasInteractiveSession the same way SendToQueue already does, so an open ConPTY session's claude process can't be orphaned by deleting its task out from under it. Row removal (in-UI delete and the delta-refresh path for externally deleted tasks) now clears SelectedTask via SelectFrom(null, "row-removed") when the removed row was selected, so the detail pane no longer keeps showing a deleted task with live buttons.
This commit is contained in:
@@ -1002,7 +1002,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
private void OnBoundTaskPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(TaskRowViewModel.HasInteractiveSession))
|
||||
{
|
||||
ResetAndRetryCommand.NotifyCanExecuteChanged();
|
||||
DeleteTaskCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -1099,7 +1102,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
CloseDetail?.Invoke();
|
||||
}
|
||||
|
||||
private bool CanDeleteTask() => Task != null && _worker.IsConnected;
|
||||
private bool CanDeleteTask() => Task != null && _worker.IsConnected && !Task.HasInteractiveSession;
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task CommitSubtaskEditAsync(SubtaskRowViewModel? row)
|
||||
|
||||
@@ -219,6 +219,13 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
}
|
||||
public string? PlanningDisabledReason => PlanningGate().Reason;
|
||||
|
||||
// Deleting out from under a live ConPTY session would orphan the claude process in Mission
|
||||
// Control (it never gets a task-deleted signal) — same rationale as SendToQueueGate.
|
||||
private (bool Can, string? Reason) DeleteGate() =>
|
||||
HasInteractiveSession ? (false, Loc.T("tasks.reasonInteractiveSession")) : (true, null);
|
||||
public bool CanDeleteTask => DeleteGate().Can;
|
||||
public string? DeleteDisabledReason => DeleteGate().Reason;
|
||||
|
||||
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).
|
||||
@@ -402,6 +409,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(StatusChipTooltip));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
OnPropertyChanged(nameof(SendToQueueDisabledReason));
|
||||
OnPropertyChanged(nameof(CanDeleteTask));
|
||||
OnPropertyChanged(nameof(DeleteDisabledReason));
|
||||
}
|
||||
|
||||
partial void OnFailureReasonChanged(string? value)
|
||||
|
||||
@@ -311,20 +311,28 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
|
||||
var existing = Items.FirstOrDefault(r => r.Id == taskId);
|
||||
var removed = false;
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
if (existing is not null) Items.Remove(existing);
|
||||
if (existing is not null) { Items.Remove(existing); removed = true; }
|
||||
}
|
||||
else
|
||||
{
|
||||
var matches = TaskMatchesList(entity, list);
|
||||
if (existing is not null && matches) existing.UpdateFromEntity(entity);
|
||||
else if (existing is not null) Items.Remove(existing);
|
||||
else if (existing is not null) { Items.Remove(existing); removed = true; }
|
||||
else if (matches) { LoadForList(list); return; }
|
||||
else return;
|
||||
}
|
||||
|
||||
// The row is gone from Items but SelectedTask (and the bound detail pane) would still
|
||||
// point at it — happens when a task is deleted externally (MCP/another session) while
|
||||
// selected here, since that path never runs SelectFrom(null, ...) like the in-UI delete
|
||||
// does.
|
||||
if (removed && SelectedTask == existing)
|
||||
SelectFrom(null, "row-removed");
|
||||
|
||||
// Keep the parent's HasQueuedSubtasks flag in sync when a child's status flips.
|
||||
if (entity is not null && !string.IsNullOrEmpty(entity.ParentTaskId))
|
||||
{
|
||||
@@ -1191,7 +1199,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand]
|
||||
private async Task DeleteTaskAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || _worker is null) return;
|
||||
if (row is null || row.HasInteractiveSession || _worker is null) return;
|
||||
if (ConfirmAsync is not null)
|
||||
{
|
||||
var ok = await ConfirmAsync(Loc.T("vm.tasksIsland.deleteTaskConfirm", row.Title));
|
||||
@@ -1216,6 +1224,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
|
||||
Items.Remove(row);
|
||||
if (SelectedTask == row) SelectFrom(null, "row-removed");
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
@@ -98,7 +98,7 @@ public partial class TaskRowView : UserControl
|
||||
menu.Items.Add(schedule);
|
||||
|
||||
menu.Items.Add(new Separator());
|
||||
menu.Items.Add(MakeItem("tasks.ctxDeleteTask", OnDeleteTaskClick));
|
||||
menu.Items.Add(MakeItem("tasks.ctxDeleteTask", OnDeleteTaskClick, reason: row.DeleteDisabledReason));
|
||||
|
||||
menu.Open(border);
|
||||
e.Handled = true;
|
||||
|
||||
Reference in New Issue
Block a user