Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs
T
mika kuns 57c61a2043 fix(ui): gate Mission Control submit-for-review, add retry, fix event leak
Submit for Review is now disabled while a ConPTY pane is starting, has
failed to launch, or has already exited, and MissionControlViewModel
guards against a rapid double-click racing two SubmitTaskForReviewAsync
calls. A failed launch no longer permanently occupies its TaskId dedupe
slot -- a Retry button re-fetches the launch spec and restarts the pane
in place. CloseConPtySession/Dispose now also unsubscribe
SubmitForReviewRequested, matching the other pane event handlers. Also
fixes the pre-existing nullable-dereference warning in
IslandsShellViewModel.SyncInteractiveSessionChips.
2026-08-06 13:42:06 +02:00

128 lines
5.2 KiB
C#

using System;
using System.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.ViewModels.MissionControl;
/// <summary>
/// Command Center pane hosting an embedded ConPTY terminal for either one task's interactive
/// Claude session, or an ad-hoc/free session in a user-chosen directory (no task, <see cref="TaskId"/>
/// is null — ad-hoc panes are never deduped, unlike task-based ones).
/// </summary>
public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControlPane, IDisposable
{
public string? TaskId { get; }
// Only a task-based pane can be submitted for review (an ad-hoc directory session has no task).
public bool IsTaskBased => TaskId is not null;
[ObservableProperty] private string _displayTitle;
[ObservableProperty] private InteractiveTerminalViewModel _terminal = new();
/// <summary>Set by the host (Mission Control) while a submit-for-review round trip is in
/// flight, so a rapid double-click can't race two submissions for the same task.</summary>
[ObservableProperty] private bool _isSubmitPending;
/// <summary>Raised when the terminal failed to start — the host surfaces this via the footer error strip.</summary>
public event Action<string>? ErrorReported;
/// <summary>Set by the host (Mission Control) to remove this pane from its collection.</summary>
public Action<ConPtyPaneViewModel>? CloseRequested { get; set; }
/// <summary>Raised when the user submits this task's hand-driven work for review; the host
/// commits the worktree and moves the task to WaitingForReview.</summary>
public event Action<string>? SubmitForReviewRequested;
/// <summary>Task-based pane — dedup'd by <see cref="TaskId"/>. Pass null for an ad-hoc pane
/// (no task, never deduped); prefer <see cref="CreateAdHoc"/> at ad-hoc call sites.
/// <paramref name="descriptorFactory"/> is resolved by the pane itself (in <see cref="Start"/>)
/// so the tile — and its starting spinner — is visible while the launch spec is still being
/// fetched. The host must wire its handlers and then call <see cref="Start"/>.</summary>
public ConPtyPaneViewModel(
string? taskId,
string displayTitle,
Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> descriptorFactory)
{
TaskId = taskId;
_displayTitle = displayTitle;
_descriptorFactory = descriptorFactory;
Terminal.PropertyChanged += OnTerminalPropertyChanged;
}
private readonly Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> _descriptorFactory;
/// <summary>Resolves the launch spec and spawns the session. Call after wiring
/// <see cref="ErrorReported"/> so a failed launch is not swallowed.</summary>
public void Start() => _ = StartAsync();
private async System.Threading.Tasks.Task StartAsync()
{
try
{
Terminal.Start(await _descriptorFactory());
}
catch (Exception ex)
{
Terminal.ReportStartFailure(ex.Message);
}
}
/// <summary>Ad-hoc pane — no task, no dedup.</summary>
public static ConPtyPaneViewModel CreateAdHoc(
string displayTitle,
Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> descriptorFactory)
=> new(null, displayTitle, descriptorFactory);
private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(InteractiveTerminalViewModel.StartError) && Terminal.StartError is { Length: > 0 } error)
ErrorReported?.Invoke(error);
if (e.PropertyName is nameof(InteractiveTerminalViewModel.IsStarting)
or nameof(InteractiveTerminalViewModel.StartError)
or nameof(InteractiveTerminalViewModel.HasExited))
{
SubmitForReviewCommand.NotifyCanExecuteChanged();
RetryCommand.NotifyCanExecuteChanged();
}
}
[RelayCommand]
private void Close() => CloseRequested?.Invoke(this);
private bool CanSubmitForReview() =>
IsTaskBased && !IsSubmitPending && !Terminal.IsStarting && Terminal.StartError is null && !Terminal.HasExited;
[RelayCommand(CanExecute = nameof(CanSubmitForReview))]
private void SubmitForReview()
{
if (TaskId is { } id) SubmitForReviewRequested?.Invoke(id);
}
partial void OnIsSubmitPendingChanged(bool value) => SubmitForReviewCommand.NotifyCanExecuteChanged();
// A launch failure permanently occupies the TaskId dedupe slot unless the user can retry —
// re-opening the same task would otherwise just re-focus a dead tile.
private bool CanRetry() => Terminal.HasExited && Terminal.StartError is not null;
[RelayCommand(CanExecute = nameof(CanRetry))]
private void Retry()
{
Terminal.PropertyChanged -= OnTerminalPropertyChanged;
Terminal.Dispose();
Terminal = new InteractiveTerminalViewModel();
Terminal.PropertyChanged += OnTerminalPropertyChanged;
Start();
}
public void Dispose()
{
Terminal.PropertyChanged -= OnTerminalPropertyChanged;
Terminal.Kill();
Terminal.Dispose();
}
}