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",
|
"closeSession": "Sitzung beenden",
|
||||||
"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}).",
|
||||||
"usageWarningNotDelivered": "Die Listen-Handler-Sitzung konnte nicht vor dem Usage-Limit gewarnt werden.",
|
"usageWarningNotDelivered": "Die Listen-Handler-Sitzung konnte nicht vor dem Usage-Limit gewarnt werden.",
|
||||||
"mergeHelperTitle": "Merge-Helfer",
|
"mergeHelperTitle": "Merge-Helfer",
|
||||||
"mergeHelperHandoffTitleSuffix": " (Übergabe)",
|
"mergeHelperHandoffTitleSuffix": " (Übergabe)",
|
||||||
|
|||||||
@@ -348,6 +348,7 @@
|
|||||||
"closeSession": "Kill session",
|
"closeSession": "Kill session",
|
||||||
"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}).",
|
||||||
"usageWarningNotDelivered": "Couldn't warn the list-handler session about the usage limit.",
|
"usageWarningNotDelivered": "Couldn't warn the list-handler session about the usage limit.",
|
||||||
"mergeHelperTitle": "Merge Helper",
|
"mergeHelperTitle": "Merge Helper",
|
||||||
"mergeHelperHandoffTitleSuffix": " (Handoff)",
|
"mergeHelperHandoffTitleSuffix": " (Handoff)",
|
||||||
|
|||||||
@@ -36,14 +36,29 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
|
|||||||
/// can show a spinner instead of an empty black pane.</summary>
|
/// can show a spinner instead of an empty black pane.</summary>
|
||||||
public bool IsStarting => !IsRunning && !HasExited && StartError is null;
|
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)
|
partial void OnIsRunningChanged(bool value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsStarting));
|
OnPropertyChanged(nameof(IsStarting));
|
||||||
if (value) _startedAtUtc = _utcNow();
|
if (value) _startedAtUtc = _utcNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnHasExitedChanged(bool value) => OnPropertyChanged(nameof(IsStarting));
|
partial void OnHasExitedChanged(bool value)
|
||||||
partial void OnStartErrorChanged(string? value) => OnPropertyChanged(nameof(IsStarting));
|
{
|
||||||
|
OnPropertyChanged(nameof(IsStarting));
|
||||||
|
OnPropertyChanged(nameof(HasExitedAfterStartup));
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnStartErrorChanged(string? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsStarting));
|
||||||
|
OnPropertyChanged(nameof(HasExitedAfterStartup));
|
||||||
|
}
|
||||||
|
|
||||||
public InteractiveTerminalViewModel() : this(() => DateTime.UtcNow)
|
public InteractiveTerminalViewModel() : this(() => DateTime.UtcNow)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using ClaudeDo.Ui.Localization;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.MissionControl;
|
namespace ClaudeDo.Ui.ViewModels.MissionControl;
|
||||||
@@ -137,8 +138,18 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
|||||||
SubmitForReviewCommand.NotifyCanExecuteChanged();
|
SubmitForReviewCommand.NotifyCanExecuteChanged();
|
||||||
RetryCommand.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
|
/// <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>
|
/// pre-usage-limit warning). False when the session isn't running.</summary>
|
||||||
public System.Threading.Tasks.Task<bool> SendLineAsync(string text) => Terminal.SendLineAsync(text);
|
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();
|
partial void OnIsAbandonPendingChanged(bool value) => AbandonRunCommand.NotifyCanExecuteChanged();
|
||||||
|
|
||||||
// A launch failure permanently occupies the TaskId dedupe slot unless the user can retry —
|
// A dead session permanently occupies the TaskId dedupe slot unless the user can retry — whether
|
||||||
// re-opening the same task would otherwise just re-focus a dead tile.
|
// it died at launch (StartError) or after (HasExitedAfterStartup), the same descriptor factory
|
||||||
private bool CanRetry() => Terminal.HasExited && Terminal.StartError is not null;
|
// can always relaunch it, so any HasExited state is retryable.
|
||||||
|
private bool CanRetry() => Terminal.HasExited;
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanRetry))]
|
[RelayCommand(CanExecute = nameof(CanRetry))]
|
||||||
private void Retry()
|
private void Retry()
|
||||||
|
|||||||
@@ -55,6 +55,27 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</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 -->
|
<!-- Embedded ConPTY terminal, with a starting overlay until the session is spawned -->
|
||||||
<Panel>
|
<Panel>
|
||||||
<views:InteractiveTerminalView DataContext="{Binding Terminal}" />
|
<views:InteractiveTerminalView DataContext="{Binding Terminal}" />
|
||||||
|
|||||||
@@ -151,6 +151,30 @@ public class ConPtyPaneViewModelTests
|
|||||||
Assert.True(pane.RetryCommand.CanExecute(null));
|
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) ────────────────────
|
// ── SessionDiedAfterStartup (feeds the worker's usage-limit auto-restart) ────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
+25
@@ -36,6 +36,7 @@ public class InteractiveTerminalViewModelTests
|
|||||||
|
|
||||||
Assert.True(terminal.HasExited);
|
Assert.True(terminal.HasExited);
|
||||||
Assert.Null(terminal.StartError);
|
Assert.Null(terminal.StartError);
|
||||||
|
Assert.True(terminal.HasExitedAfterStartup); // no StartError banner, so the host needs this signal instead
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -48,6 +49,30 @@ public class InteractiveTerminalViewModelTests
|
|||||||
|
|
||||||
Assert.True(terminal.HasExited);
|
Assert.True(terminal.HasExited);
|
||||||
Assert.Null(terminal.StartError);
|
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;
|
// ── ExitedAfterStartup (a session that ran, then broke -- used to be a silent dead tile;
|
||||||
|
|||||||
Reference in New Issue
Block a user