From aac84e48b70df11443e6daf79c6683810bf25bab Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 6 Aug 2026 12:43:55 +0200 Subject: [PATCH] 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. --- .../Services/PtyTerminalSession.cs | 7 +++ .../Views/MissionControl/ConPtyPaneHost.cs | 48 +++++++++++++++++++ .../MissionControl/MissionControlView.axaml | 5 +- .../MissionControlView.axaml.cs | 14 ++++++ 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneHost.cs diff --git a/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs b/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs index 29b2d920..1be2a2cd 100644 --- a/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs +++ b/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs @@ -46,6 +46,13 @@ public sealed class PtyTerminalSession : IDisposable control.StartingDirectory = descriptor.Cwd; 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; } diff --git a/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneHost.cs b/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneHost.cs new file mode 100644 index 00000000..10854650 --- /dev/null +++ b/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneHost.cs @@ -0,0 +1,48 @@ +using System.Runtime.CompilerServices; +using Avalonia.Controls; +using ClaudeDo.Ui.ViewModels.MissionControl; + +namespace ClaudeDo.Ui.Views.MissionControl; + +/// +/// Hosts the single long-lived of a . +/// 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 +/// puts the control in permanent reparent mode +/// (detach never kills the child process). +/// +public sealed class ConPtyPaneHost : Decorator +{ + // Keyed weakly so a closed pane's view dies with its view model. + private static readonly ConditionalWeakTable Views = new(); + + public ConPtyPaneHost() + { + DataContextChanged += (_, _) => Reclaim(); + AttachedToVisualTree += (_, _) => Reclaim(); + } + + /// 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. + 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; + } +} diff --git a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml index 02ae99f5..26aa73bb 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml +++ b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml @@ -8,8 +8,11 @@ x:DataType="vm:MissionControlViewModel" x:Class="ClaudeDo.Ui.Views.MissionControl.MissionControlView"> + - + diff --git a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs index 2f544297..67197117 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs @@ -1,11 +1,13 @@ using System; using System.Collections.Specialized; using System.ComponentModel; +using System.Linq; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Platform.Storage; +using Avalonia.VisualTree; using ClaudeDo.Ui.ViewModels; namespace ClaudeDo.Ui.Views.MissionControl; @@ -73,6 +75,18 @@ public partial class MissionControlView : UserControl RebuildOverviewGrid(); else if (e.PropertyName == nameof(MissionControlViewModel.HasQueued)) 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()) + host.Reclaim(); } // The queue column is Pixel-widthed (so the splitter can drag it) — when nothing is