fix(ui): close interactive-session gate bypasses in reset-and-retry and plan queueing

Reset & Retry discarded the branch and queued an autonomous run even while the
user had an interactive ConPTY pane open on the task, and finalizing a plan
queued every child unconditionally (the hub has no notion of a UI-hosted
session) — both bypassed the HasInteractiveSession gate added for
CanSendToQueue. CanResetAndRetry now checks it too, with a subscription on the
bound task so the command re-evaluates when the flag flips without Task
itself changing; SendToQueueAsync now blocks queuing the whole plan and
surfaces the affected child titles when any child has an open session.
This commit is contained in:
mika kuns
2026-08-06 14:30:24 +02:00
parent bac8387069
commit 166021049a
6 changed files with 305 additions and 3 deletions
@@ -423,6 +423,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
public void Dispose()
{
if (_subscribedTask is not null)
_subscribedTask.PropertyChanged -= OnBoundTaskPropertyChanged;
Monitor.PropertyChanged -= OnMonitorPropertyChanged;
Monitor.Dispose();
Loc.LanguageChanged -= _langChangedHandler;
@@ -907,14 +909,33 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
WorktreeStateLabel, _listWorkingDir);
// Tracks whichever row we last subscribed to, so a later Task switch can unsubscribe the old
// one cleanly even though the generated OnTaskChanged only hands us the new value.
private TaskRowViewModel? _subscribedTask;
partial void OnTaskChanged(TaskRowViewModel? value)
{
if (_subscribedTask is not null)
_subscribedTask.PropertyChanged -= OnBoundTaskPropertyChanged;
_subscribedTask = value;
if (value is not null)
value.PropertyChanged += OnBoundTaskPropertyChanged;
ReviewDiffViewed = false;
Merge.SyncTaskContext(Task?.Id, Task?.Title, Task?.IsPlanningParent == true);
NotifySessionSections();
OnPropertyChanged(nameof(CanAcceptDrop));
}
// The bound row's HasInteractiveSession can flip from outside (Mission Control opening/closing
// a ConPTY pane) without Task itself changing, so ResetAndRetryCommand needs its own listener
// to stay in sync with the gate in CanResetAndRetry.
private void OnBoundTaskPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(TaskRowViewModel.HasInteractiveSession))
ResetAndRetryCommand.NotifyCanExecuteChanged();
}
[RelayCommand]
private void CloseDetails() => CloseDetail?.Invoke();
@@ -1133,8 +1154,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
catch { /* offline */ }
}
// Reset & Retry discards the branch/uncommitted work and queues an autonomous run into the
// same worktree — must stay off while the user is hand-editing it in an interactive ConPTY
// pane, same reasoning as TaskRowViewModel.CanSendToQueue.
private bool CanResetAndRetry() =>
Task != null && _worker.IsConnected && ShowResetAndRetry;
Task != null && _worker.IsConnected && ShowResetAndRetry && !Task.HasInteractiveSession;
// Set once the user opens the diff/combined-diff for the current review. Reset on
// task switch and on every state change (a new run means a new diff to read), so
@@ -837,6 +837,19 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// A finalized planning parent queues its plan (children sequentially), not itself.
if (row.CanQueuePlan)
{
// The hub's QueuePlanningSubtasksAsync queues every Idle child unconditionally — it has
// no notion of a UI-hosted ConPTY session. Block the whole plan if any child has one open,
// otherwise that child's worktree would get an autonomous run racing the user's own edits.
var interactiveChildren = Items
.Where(r => r.ParentTaskId == row.Id && r.HasInteractiveSession)
.Select(r => r.Title)
.ToList();
if (interactiveChildren.Count > 0)
{
ErrorReported?.Invoke(Loc.T(
"vm.tasksIsland.queuePlanBlockedInteractive", string.Join(", ", interactiveChildren)));
return;
}
await QueuePlanningSubtasksAsync(row);
return;
}