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:
@@ -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,7 +138,17 @@ 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>
|
||||
@@ -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}" />
|
||||
|
||||
@@ -151,6 +151,30 @@ public class ConPtyPaneViewModelTests
|
||||
Assert.True(pane.RetryCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
// ── HasExitedAfterStartup banner (fix: a mid-run death used to leave a silent dead tile — no
|
||||
// StartError, no signal at all) ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RetryCommand_Enabled_AfterDeathAfterStartup()
|
||||
{
|
||||
using var pane = NewTaskPane(NeverCompletes);
|
||||
|
||||
DieAfterRunning(pane, exitCode: 1);
|
||||
|
||||
Assert.True(pane.Terminal.HasExitedAfterStartup);
|
||||
Assert.True(pane.RetryCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExitedBannerText_IncludesExitCode()
|
||||
{
|
||||
using var pane = NewTaskPane(NeverCompletes);
|
||||
|
||||
DieAfterRunning(pane, exitCode: 42);
|
||||
|
||||
Assert.Contains("42", pane.ExitedBannerText);
|
||||
}
|
||||
|
||||
// ── SessionDiedAfterStartup (feeds the worker's usage-limit auto-restart) ────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
+25
@@ -36,6 +36,7 @@ public class InteractiveTerminalViewModelTests
|
||||
|
||||
Assert.True(terminal.HasExited);
|
||||
Assert.Null(terminal.StartError);
|
||||
Assert.True(terminal.HasExitedAfterStartup); // no StartError banner, so the host needs this signal instead
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -48,6 +49,30 @@ public class InteractiveTerminalViewModelTests
|
||||
|
||||
Assert.True(terminal.HasExited);
|
||||
Assert.Null(terminal.StartError);
|
||||
Assert.True(terminal.HasExitedAfterStartup);
|
||||
}
|
||||
|
||||
// ── HasExitedAfterStartup (drives the host's "session ended" banner — every death that
|
||||
// ISN'T already covered by StartError) ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FreshTerminal_HasExitedAfterStartupIsFalse()
|
||||
{
|
||||
using var terminal = new InteractiveTerminalViewModel(() => LaunchedAt);
|
||||
|
||||
Assert.False(terminal.HasExitedAfterStartup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessExitsNonzero_WithinGraceWindow_HasExitedAfterStartupIsFalse()
|
||||
{
|
||||
using var terminal = new InteractiveTerminalViewModel(() => LaunchedAt);
|
||||
terminal.IsRunning = true;
|
||||
|
||||
terminal.OnSessionProcessExited(null, 1); // startup failure — StartError covers it instead
|
||||
|
||||
Assert.NotNull(terminal.StartError);
|
||||
Assert.False(terminal.HasExitedAfterStartup);
|
||||
}
|
||||
|
||||
// ── ExitedAfterStartup (a session that ran, then broke -- used to be a silent dead tile;
|
||||
|
||||
Reference in New Issue
Block a user