From 256bea5ffab85fa50deb47b6b93c5243bd88c47b Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 10 Sep 2026 13:09:03 +0200 Subject: [PATCH] fix(ui): show a banner when a ConPTY session dies after startup InteractiveTerminalViewModel.HasExitedAfterStartup now covers every post-startup death that ExitedAfterStartup alone missed (crash, clean exit, quiet exit inside the grace window) -- previously the tile just stayed looking alive/thinking with zero signal. ConPtyPaneView binds a new banner to it (message + Retry + Close); CanRetry simplifies to "any HasExited state" since the same descriptor factory can relaunch either kind of death. HandlerRestartCoordinator's worker-side auto-restart path is untouched. --- src/ClaudeDo.Localization/locales/de.json | 1 + src/ClaudeDo.Localization/locales/en.json | 1 + .../InteractiveTerminalViewModel.cs | 19 ++++++++++++-- .../MissionControl/ConPtyPaneViewModel.cs | 18 ++++++++++--- .../Views/MissionControl/ConPtyPaneView.axaml | 21 ++++++++++++++++ .../ConPtyPaneViewModelTests.cs | 24 ++++++++++++++++++ .../InteractiveTerminalViewModelTests.cs | 25 +++++++++++++++++++ 7 files changed, 104 insertions(+), 5 deletions(-) diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 356d064d..2bb0b81e 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -348,6 +348,7 @@ "closeSession": "Sitzung beenden", "conptyLaunchFailed": "Interaktive Sitzung konnte nicht geöffnet werden: {0}", "conptyStarting": "Sitzung wird gestartet…", + "conptyExited": "Sitzung beendet (Code {0}).", "usageWarningNotDelivered": "Die Listen-Handler-Sitzung konnte nicht vor dem Usage-Limit gewarnt werden.", "mergeHelperTitle": "Merge-Helfer", "mergeHelperHandoffTitleSuffix": " (Übergabe)", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index a52b1569..c013ac84 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -348,6 +348,7 @@ "closeSession": "Kill session", "conptyLaunchFailed": "Couldn't open interactive session: {0}", "conptyStarting": "Starting session…", + "conptyExited": "Session ended (code {0}).", "usageWarningNotDelivered": "Couldn't warn the list-handler session about the usage limit.", "mergeHelperTitle": "Merge Helper", "mergeHelperHandoffTitleSuffix": " (Handoff)", diff --git a/src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs index b0fed886..370d6906 100644 --- a/src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs @@ -36,14 +36,29 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo /// can show a spinner instead of an empty black pane. public bool IsStarting => !IsRunning && !HasExited && StartError is null; + /// True once the process has died without a already covering it — + /// i.e. every death that isn't a launch-time failure: a crash after running a while, a clean exit, + /// or a quiet exit inside the startup grace window. Drives the host's "session ended" banner so a + /// dead pane never looks like it's still running/thinking. + public bool HasExitedAfterStartup => HasExited && StartError is null; + partial void OnIsRunningChanged(bool value) { OnPropertyChanged(nameof(IsStarting)); if (value) _startedAtUtc = _utcNow(); } - partial void OnHasExitedChanged(bool value) => OnPropertyChanged(nameof(IsStarting)); - partial void OnStartErrorChanged(string? value) => OnPropertyChanged(nameof(IsStarting)); + partial void OnHasExitedChanged(bool value) + { + OnPropertyChanged(nameof(IsStarting)); + OnPropertyChanged(nameof(HasExitedAfterStartup)); + } + + partial void OnStartErrorChanged(string? value) + { + OnPropertyChanged(nameof(IsStarting)); + OnPropertyChanged(nameof(HasExitedAfterStartup)); + } public InteractiveTerminalViewModel() : this(() => DateTime.UtcNow) { diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs index 5737ccd9..bf9b789b 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs @@ -2,6 +2,7 @@ using System; using System.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; namespace ClaudeDo.Ui.ViewModels.MissionControl; @@ -137,8 +138,18 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable SubmitForReviewCommand.NotifyCanExecuteChanged(); RetryCommand.NotifyCanExecuteChanged(); } + + if (e.PropertyName is nameof(InteractiveTerminalViewModel.HasExitedAfterStartup) + or nameof(InteractiveTerminalViewModel.ExitCode)) + { + OnPropertyChanged(nameof(ExitedBannerText)); + } } + /// "Session ended (code N)" text for — + /// computed here (rather than formatted in XAML) since loc:Tr has no binding-arg support. + public string ExitedBannerText => Loc.T("missionControl.conptyExited", Terminal.ExitCode ?? 0); + /// Types a message into this pane's live session and submits it (the worker's /// pre-usage-limit warning). False when the session isn't running. public System.Threading.Tasks.Task SendLineAsync(string text) => Terminal.SendLineAsync(text); @@ -167,9 +178,10 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable partial void OnIsAbandonPendingChanged(bool value) => AbandonRunCommand.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; + // A dead session permanently occupies the TaskId dedupe slot unless the user can retry — whether + // it died at launch (StartError) or after (HasExitedAfterStartup), the same descriptor factory + // can always relaunch it, so any HasExited state is retryable. + private bool CanRetry() => Terminal.HasExited; [RelayCommand(CanExecute = nameof(CanRetry))] private void Retry() diff --git a/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml b/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml index f6048afb..abf6854e 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml +++ b/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml @@ -55,6 +55,27 @@ + + + + +