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.
This commit is contained in:
mika kuns
2026-09-10 13:32:19 +02:00
parent 2b0562b2ed
commit 256bea5ffa
7 changed files with 104 additions and 5 deletions
@@ -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)",
@@ -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)",
@@ -36,14 +36,29 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
/// can show a spinner instead of an empty black pane.</summary>
public bool IsStarting => !IsRunning && !HasExited && StartError is null;
/// <summary>True once the process has died without a <see cref="StartError"/> 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.</summary>
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)
{
@@ -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));
}
}
/// <summary>"Session ended (code N)" text for <see cref="InteractiveTerminalViewModel.HasExitedAfterStartup"/> —
/// computed here (rather than formatted in XAML) since <c>loc:Tr</c> has no binding-arg support.</summary>
public string ExitedBannerText => Loc.T("missionControl.conptyExited", Terminal.ExitCode ?? 0);
/// <summary>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.</summary>
public System.Threading.Tasks.Task<bool> 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()
@@ -55,6 +55,27 @@
</Grid>
</Border>
<!-- Session-ended banner: process died without a start-failure already covering it -->
<Border DockPanel.Dock="Top"
IsVisible="{Binding Terminal.HasExitedAfterStartup}"
Background="{DynamicResource ErrorTintBrush}"
BorderBrush="{DynamicResource BloodBrush}"
BorderThickness="0,0,0,1" Padding="12,6">
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding ExitedBannerText}"
Foreground="{DynamicResource BloodBrush}"
TextWrapping="Wrap" VerticalAlignment="Center" />
<Button Grid.Column="1" Classes="btn" Margin="12,0,0,0"
Content="{loc:Tr missionControl.retry}"
ToolTip.Tip="{loc:Tr missionControl.retryTip}"
Command="{Binding RetryCommand}" />
<Button Grid.Column="2" Classes="btn" Margin="12,0,0,0"
Content="{loc:Tr missionControl.closeSession}"
ToolTip.Tip="{loc:Tr missionControl.closeSession}"
Command="{Binding CloseCommand}" />
</Grid>
</Border>
<!-- Embedded ConPTY terminal, with a starting overlay until the session is spawned -->
<Panel>
<views:InteractiveTerminalView DataContext="{Binding Terminal}" />