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.
This commit is contained in:
mika kuns
2026-08-06 13:42:06 +02:00
parent 0d1e3b9a6f
commit 57c61a2043
9 changed files with 299 additions and 8 deletions
@@ -0,0 +1,138 @@
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.MissionControl;
using Xunit;
namespace ClaudeDo.Ui.Tests.ViewModels.MissionControl;
public class ConPtyPaneViewModelTests
{
private static ConPtyPaneViewModel NewTaskPane(
Func<Task<TerminalLaunchDescriptor>> descriptorFactory,
string taskId = "t1")
=> new(taskId, "Some Task", descriptorFactory);
private static Task<TerminalLaunchDescriptor> NeverCompletes()
=> new TaskCompletionSource<TerminalLaunchDescriptor>().Task;
private static Task<TerminalLaunchDescriptor> Failing(string message = "boom")
=> Task.FromException<TerminalLaunchDescriptor>(new InvalidOperationException(message));
// ── CanSubmitForReview gating (fix: don't offer Submit for Review on a dead/starting pane) ──
[Fact]
public void CanSubmitForReview_False_WhileStarting()
{
using var pane = NewTaskPane(NeverCompletes);
pane.Start();
Assert.True(pane.Terminal.IsStarting);
Assert.False(pane.SubmitForReviewCommand.CanExecute(null));
}
[Fact]
public void CanSubmitForReview_False_AfterLaunchFailure()
{
using var pane = NewTaskPane(() => Failing());
pane.Start();
Assert.NotNull(pane.Terminal.StartError);
Assert.True(pane.Terminal.HasExited);
Assert.False(pane.SubmitForReviewCommand.CanExecute(null));
}
[Fact]
public void CanSubmitForReview_True_WhenRunning()
{
using var pane = NewTaskPane(NeverCompletes);
SetRunning(pane);
Assert.True(pane.SubmitForReviewCommand.CanExecute(null));
}
[Fact]
public void CanSubmitForReview_False_ForAdHocPane_EvenWhileRunning()
{
using var pane = ConPtyPaneViewModel.CreateAdHoc("Ad hoc", NeverCompletes);
SetRunning(pane);
Assert.False(pane.IsTaskBased);
Assert.False(pane.SubmitForReviewCommand.CanExecute(null));
}
[Fact]
public void CanSubmitForReview_False_WhileSubmitPending()
{
using var pane = NewTaskPane(NeverCompletes);
SetRunning(pane);
Assert.True(pane.SubmitForReviewCommand.CanExecute(null));
pane.IsSubmitPending = true;
Assert.False(pane.SubmitForReviewCommand.CanExecute(null));
pane.IsSubmitPending = false;
Assert.True(pane.SubmitForReviewCommand.CanExecute(null));
}
// ── Retry (fix: a failed launch used to permanently occupy the TaskId dedupe slot) ──────────
[Fact]
public void RetryCommand_Disabled_BeforeAndWhileStarting()
{
using var pane = NewTaskPane(NeverCompletes);
Assert.False(pane.RetryCommand.CanExecute(null));
pane.Start();
Assert.False(pane.RetryCommand.CanExecute(null)); // still starting, no failure yet
}
[Fact]
public void RetryCommand_Enabled_AfterLaunchFailure()
{
using var pane = NewTaskPane(() => Failing());
pane.Start();
Assert.True(pane.RetryCommand.CanExecute(null));
}
[Fact]
public void Retry_ReplacesTerminal_AndRefetchesDescriptor()
{
var callCount = 0;
Func<Task<TerminalLaunchDescriptor>> factory = () =>
{
callCount++;
return callCount == 1 ? Failing() : NeverCompletes();
};
using var pane = NewTaskPane(factory);
pane.Start();
Assert.True(pane.RetryCommand.CanExecute(null));
var terminalBeforeRetry = pane.Terminal;
pane.RetryCommand.Execute(null);
Assert.Equal(2, callCount);
Assert.NotSame(terminalBeforeRetry, pane.Terminal);
Assert.Null(pane.Terminal.StartError);
Assert.False(pane.Terminal.HasExited);
Assert.False(pane.RetryCommand.CanExecute(null));
}
[Fact]
public void Retry_FailsAgain_StillOffersRetry()
{
using var pane = NewTaskPane(() => Failing());
pane.Start();
Assert.True(pane.RetryCommand.CanExecute(null));
pane.RetryCommand.Execute(null);
Assert.NotNull(pane.Terminal.StartError);
Assert.True(pane.RetryCommand.CanExecute(null));
}
private static void SetRunning(ConPtyPaneViewModel pane) => pane.Terminal.IsRunning = true;
}