feat(ui): confirm before closing a running ConPTY session
The tile's own X killed a live Claude session with no confirmation, unlike the semantically similar Abandon-run action. Gate the close behind the same ConfirmAsync mechanism, but only when Terminal.IsRunning -- a dead session (StartError or exited-after-startup) still closes immediately since the X there is pure cleanup.
This commit is contained in:
@@ -380,6 +380,7 @@
|
|||||||
"focusMode": "Fokus",
|
"focusMode": "Fokus",
|
||||||
"overviewMode": "Übersicht",
|
"overviewMode": "Übersicht",
|
||||||
"closeSession": "Sitzung beenden",
|
"closeSession": "Sitzung beenden",
|
||||||
|
"closeSessionConfirm": "Laufende Sitzung beenden? Der Claude-Prozess wird abgebrochen.",
|
||||||
"conptyLaunchFailed": "Interaktive Sitzung konnte nicht geöffnet werden: {0}",
|
"conptyLaunchFailed": "Interaktive Sitzung konnte nicht geöffnet werden: {0}",
|
||||||
"conptyStarting": "Sitzung wird gestartet…",
|
"conptyStarting": "Sitzung wird gestartet…",
|
||||||
"conptyExited": "Sitzung beendet (Code {0}).",
|
"conptyExited": "Sitzung beendet (Code {0}).",
|
||||||
|
|||||||
@@ -380,6 +380,7 @@
|
|||||||
"focusMode": "Focus",
|
"focusMode": "Focus",
|
||||||
"overviewMode": "Overview",
|
"overviewMode": "Overview",
|
||||||
"closeSession": "Kill session",
|
"closeSession": "Kill session",
|
||||||
|
"closeSessionConfirm": "End the running session? The Claude process will be aborted.",
|
||||||
"conptyLaunchFailed": "Couldn't open interactive session: {0}",
|
"conptyLaunchFailed": "Couldn't open interactive session: {0}",
|
||||||
"conptyStarting": "Starting session…",
|
"conptyStarting": "Starting session…",
|
||||||
"conptyExited": "Session ended (code {0}).",
|
"conptyExited": "Session ended (code {0}).",
|
||||||
|
|||||||
@@ -449,7 +449,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
private void AddConPtyPane(ConPtyPaneViewModel pane)
|
private void AddConPtyPane(ConPtyPaneViewModel pane)
|
||||||
{
|
{
|
||||||
pane.ErrorReported += OnConPtyPaneError;
|
pane.ErrorReported += OnConPtyPaneError;
|
||||||
pane.CloseRequested += CloseConPtySession;
|
pane.CloseRequested += OnPaneCloseRequested;
|
||||||
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
||||||
pane.SessionDiedAfterStartup += OnPaneSessionDied;
|
pane.SessionDiedAfterStartup += OnPaneSessionDied;
|
||||||
pane.AbandonRunRequested += OnPaneAbandonRun;
|
pane.AbandonRunRequested += OnPaneAbandonRun;
|
||||||
@@ -543,11 +543,27 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tile's own X: a reflex click must not silently kill a live session the way it would a
|
||||||
|
// regular window. Confirm only when the terminal is actually running -- a dead one (StartError,
|
||||||
|
// or HasExited i.e. the #290 exited banner) is pure cleanup and closes straight away, same as it
|
||||||
|
// always did. Every other close path (handoff replacing a pane, submit-for-review, abandon-run)
|
||||||
|
// still calls CloseConPtySession directly and is unaffected.
|
||||||
|
private async void OnPaneCloseRequested(ConPtyPaneViewModel pane)
|
||||||
|
{
|
||||||
|
if (pane.Terminal.IsRunning)
|
||||||
|
{
|
||||||
|
if (ConfirmAsync is null) return;
|
||||||
|
if (!await ConfirmAsync(Loc.T("missionControl.closeSessionConfirm"))) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CloseConPtySession(pane);
|
||||||
|
}
|
||||||
|
|
||||||
private void CloseConPtySession(ConPtyPaneViewModel pane)
|
private void CloseConPtySession(ConPtyPaneViewModel pane)
|
||||||
{
|
{
|
||||||
if (!ConPtySessions.Contains(pane)) return;
|
if (!ConPtySessions.Contains(pane)) return;
|
||||||
pane.ErrorReported -= OnConPtyPaneError;
|
pane.ErrorReported -= OnConPtyPaneError;
|
||||||
pane.CloseRequested -= CloseConPtySession;
|
pane.CloseRequested -= OnPaneCloseRequested;
|
||||||
pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
||||||
pane.SessionDiedAfterStartup -= OnPaneSessionDied;
|
pane.SessionDiedAfterStartup -= OnPaneSessionDied;
|
||||||
pane.AbandonRunRequested -= OnPaneAbandonRun;
|
pane.AbandonRunRequested -= OnPaneAbandonRun;
|
||||||
@@ -601,7 +617,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
foreach (var c in ConPtySessions.ToList())
|
foreach (var c in ConPtySessions.ToList())
|
||||||
{
|
{
|
||||||
c.ErrorReported -= OnConPtyPaneError;
|
c.ErrorReported -= OnConPtyPaneError;
|
||||||
c.CloseRequested -= CloseConPtySession;
|
c.CloseRequested -= OnPaneCloseRequested;
|
||||||
c.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
c.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
||||||
c.SessionDiedAfterStartup -= OnPaneSessionDied;
|
c.SessionDiedAfterStartup -= OnPaneSessionDied;
|
||||||
c.AbandonRunRequested -= OnPaneAbandonRun;
|
c.AbandonRunRequested -= OnPaneAbandonRun;
|
||||||
|
|||||||
@@ -1072,4 +1072,59 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
Assert.Equal(new[] { "t1", "t2" }, worker.Cancelled);
|
Assert.Equal(new[] { "t1", "t2" }, worker.Cancelled);
|
||||||
Assert.Empty(vm.ConPtySessions);
|
Assert.Empty(vm.ConPtySessions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tile's own X must not silently kill a live session -- a reflex click on a running pane
|
||||||
|
// has to go through the same confirm mechanism as Abandon Run.
|
||||||
|
[Fact]
|
||||||
|
public async Task Close_RunningSession_ConfirmDeclined_LeavesSessionOpen()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
using var vm = BuildVm(worker);
|
||||||
|
await vm.OpenConPtySessionAsync("t1");
|
||||||
|
var pane = vm.ConPtySessions[0];
|
||||||
|
pane.Terminal.IsRunning = true;
|
||||||
|
string? confirmMessage = null;
|
||||||
|
vm.ConfirmAsync = msg => { confirmMessage = msg; return Task.FromResult(false); };
|
||||||
|
|
||||||
|
pane.CloseCommand.Execute(null);
|
||||||
|
await Task.Delay(20);
|
||||||
|
|
||||||
|
Assert.NotNull(confirmMessage);
|
||||||
|
Assert.Single(vm.ConPtySessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Close_RunningSession_ConfirmAccepted_ClosesPane()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
using var vm = BuildVm(worker);
|
||||||
|
await vm.OpenConPtySessionAsync("t1");
|
||||||
|
var pane = vm.ConPtySessions[0];
|
||||||
|
pane.Terminal.IsRunning = true;
|
||||||
|
vm.ConfirmAsync = _ => Task.FromResult(true);
|
||||||
|
|
||||||
|
pane.CloseCommand.Execute(null);
|
||||||
|
await Task.Delay(20);
|
||||||
|
|
||||||
|
Assert.Empty(vm.ConPtySessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dead session (start failure or exited-after-startup) is pure cleanup -- no confirm needed.
|
||||||
|
[Fact]
|
||||||
|
public async Task Close_DeadSession_ClosesWithoutConfirming()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
using var vm = BuildVm(worker);
|
||||||
|
await vm.OpenConPtySessionAsync("t1");
|
||||||
|
var pane = vm.ConPtySessions[0];
|
||||||
|
pane.Terminal.ReportStartFailure("boom");
|
||||||
|
var confirmCalled = false;
|
||||||
|
vm.ConfirmAsync = _ => { confirmCalled = true; return Task.FromResult(true); };
|
||||||
|
|
||||||
|
pane.CloseCommand.Execute(null);
|
||||||
|
await Task.Delay(20);
|
||||||
|
|
||||||
|
Assert.False(confirmCalled);
|
||||||
|
Assert.Empty(vm.ConPtySessions);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user