fix(ui): keep ConPTY sessions alive across Mission Control layout rebuilds
TerminalView kills its child process on any logical-tree detach unless BeginReparent() suppressed it, and RebuildOverviewGrid() detaches every existing pane whenever one is added - so opening a second interactive session killed the first. Focus-mode tab switches had the same defect. PtyTerminalSession now puts the control in permanent reparent mode after launch (teardown stays explicit via ConPtyPaneViewModel.Dispose -> Kill), and the new ConPtyPaneHost reparents each pane's single long-lived view across grid rebuilds and tab switches instead of letting the DataTemplate instantiate a dead replacement.
This commit is contained in:
@@ -46,6 +46,13 @@ public sealed class PtyTerminalSession : IDisposable
|
|||||||
control.StartingDirectory = descriptor.Cwd;
|
control.StartingDirectory = descriptor.Cwd;
|
||||||
|
|
||||||
await control.LaunchProcess();
|
await control.LaunchProcess();
|
||||||
|
|
||||||
|
// Permanent reparent mode: TerminalView.OnDetachedFromLogicalTree kills the child
|
||||||
|
// process unless BeginReparent() suppressed it, and Mission Control detaches pane
|
||||||
|
// views routinely (overview-grid rebuilds on pane add/remove, focus-mode tab
|
||||||
|
// switches). ClaudeDo owns teardown explicitly instead — ConPtyPaneViewModel.Dispose
|
||||||
|
// calls Kill() when a pane closes — so EndReparent is deliberately never called.
|
||||||
|
control.BeginReparent();
|
||||||
IsRunning = true;
|
IsRunning = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using ClaudeDo.Ui.ViewModels.MissionControl;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Views.MissionControl;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hosts the single long-lived <see cref="ConPtyPaneView"/> of a <see cref="ConPtyPaneViewModel"/>.
|
||||||
|
/// The Mission Control layouts recreate their DataTemplate content freely (the overview grid is
|
||||||
|
/// rebuilt from scratch on every pane/column change, focus-mode tabs re-present on selection), but
|
||||||
|
/// the running PTY session is bound to the original TerminalControl — a freshly instantiated view
|
||||||
|
/// would render a dead, empty terminal. This host therefore reparents the pane's one real view
|
||||||
|
/// instead of letting the template create a new one. Safe while the session runs because
|
||||||
|
/// <see cref="Services.PtyTerminalSession"/> puts the control in permanent reparent mode
|
||||||
|
/// (detach never kills the child process).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConPtyPaneHost : Decorator
|
||||||
|
{
|
||||||
|
// Keyed weakly so a closed pane's view dies with its view model.
|
||||||
|
private static readonly ConditionalWeakTable<ConPtyPaneViewModel, ConPtyPaneView> Views = new();
|
||||||
|
|
||||||
|
public ConPtyPaneHost()
|
||||||
|
{
|
||||||
|
DataContextChanged += (_, _) => Reclaim();
|
||||||
|
AttachedToVisualTree += (_, _) => Reclaim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Takes ownership of the pane's cached view, stealing it from whichever host showed
|
||||||
|
/// it last — but only while effectively visible, so the hidden layout (overview grid vs. focus
|
||||||
|
/// tabs) never yanks the view away from the visible one.</summary>
|
||||||
|
public void Reclaim()
|
||||||
|
{
|
||||||
|
var vm = DataContext as ConPtyPaneViewModel;
|
||||||
|
|
||||||
|
// A recycled host now bound to a different pane must not keep showing the old view.
|
||||||
|
// The cached views carry their own local DataContext, so this check can't be fooled
|
||||||
|
// by DataContext inheritance.
|
||||||
|
if (Child is ConPtyPaneView stale && !ReferenceEquals(stale.DataContext, vm))
|
||||||
|
Child = null;
|
||||||
|
|
||||||
|
if (vm is null || !IsEffectivelyVisible) return;
|
||||||
|
|
||||||
|
var view = Views.GetValue(vm, static key => new ConPtyPaneView { DataContext = key });
|
||||||
|
if (ReferenceEquals(Child, view)) return;
|
||||||
|
if (view.Parent is ConPtyPaneHost previous) previous.Child = null;
|
||||||
|
Child = view;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,11 @@
|
|||||||
x:DataType="vm:MissionControlViewModel"
|
x:DataType="vm:MissionControlViewModel"
|
||||||
x:Class="ClaudeDo.Ui.Views.MissionControl.MissionControlView">
|
x:Class="ClaudeDo.Ui.Views.MissionControl.MissionControlView">
|
||||||
<UserControl.DataTemplates>
|
<UserControl.DataTemplates>
|
||||||
|
<!-- ConPtyPaneHost reuses the pane's one long-lived view across layout rebuilds and
|
||||||
|
tab switches — instantiating ConPtyPaneView here directly would detach (and, without
|
||||||
|
reparent suppression, kill) the running session every time a pane is added. -->
|
||||||
<DataTemplate DataType="vmm:ConPtyPaneViewModel">
|
<DataTemplate DataType="vmm:ConPtyPaneViewModel">
|
||||||
<mc:ConPtyPaneView Margin="6" />
|
<mc:ConPtyPaneHost Margin="6" />
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</UserControl.DataTemplates>
|
</UserControl.DataTemplates>
|
||||||
<DockPanel LastChildFill="True" Background="{DynamicResource VoidBrush}">
|
<DockPanel LastChildFill="True" Background="{DynamicResource VoidBrush}">
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Specialized;
|
using System.Collections.Specialized;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
|
using System.Linq;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Layout;
|
using Avalonia.Layout;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.MissionControl;
|
namespace ClaudeDo.Ui.Views.MissionControl;
|
||||||
@@ -73,6 +75,18 @@ public partial class MissionControlView : UserControl
|
|||||||
RebuildOverviewGrid();
|
RebuildOverviewGrid();
|
||||||
else if (e.PropertyName == nameof(MissionControlViewModel.HasQueued))
|
else if (e.PropertyName == nameof(MissionControlViewModel.HasQueued))
|
||||||
UpdateQueueColumn();
|
UpdateQueueColumn();
|
||||||
|
else if (e.PropertyName == nameof(MissionControlViewModel.IsFocusMode))
|
||||||
|
// The now-visible layout's hosts must re-steal their pane views from the hidden
|
||||||
|
// one (see ConPtyPaneHost) — posted so it runs after the visibility change and
|
||||||
|
// the TabControl's content presenter have gone through a layout pass.
|
||||||
|
Avalonia.Threading.Dispatcher.UIThread.Post(ReclaimVisiblePaneHosts,
|
||||||
|
Avalonia.Threading.DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReclaimVisiblePaneHosts()
|
||||||
|
{
|
||||||
|
foreach (var host in this.GetVisualDescendants().OfType<ConPtyPaneHost>())
|
||||||
|
host.Reclaim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The queue column is Pixel-widthed (so the splitter can drag it) — when nothing is
|
// The queue column is Pixel-widthed (so the splitter can drag it) — when nothing is
|
||||||
|
|||||||
Reference in New Issue
Block a user