From 232739c168420e580bf6be4d6c4161ecf4d64ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Aslo-=C3=98stergaard?= Date: Wed, 13 May 2026 15:41:31 +0200 Subject: [PATCH] feat(lifecycle): stagger claude restart + restore loading indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three lifecycle improvements aimed at preventing ~/.claude.json corruption and making session restore feel responsive. - AppSettings.ClaudeLaunchStaggerMs (default 2000): a single setting drives the wait between consecutive Claude session starts and shutdowns. The prior hardcoded 2 s only covered restore-on-startup; close-time had no pacing at all, so two claude.exe processes could rewrite ~/.claude.json concurrently and corrupt it. - Claude-aware OnClosing: non-Claude sessions still dispose in parallel. For each Claude session the new DisposeAndWaitForExitAsync subscribes to the PTY's Exited event, calls Dispose (which triggers ClosePseudoConsole and signals the child), then awaits actual process exit (capped at 10 s so a stuck child can't hang shutdown). A small post-exit pause acts as belt-and-braces. Waiting on the real exit beats a fixed timer because claude's shutdown can take longer than the configured stagger on slow disks. - Restore-time loading indicators: OnLoaded now stages a muted placeholder sidebar row for every live session up-front, each with a pulsing accent dot and a "Launching…" subtitle. RebuildSidebarOrder walks SessionManager.Sessions in saved order, picking the live sidebar item if registered else the placeholder (Resolve helper), so the full list of icons appears the moment the window opens and items light up in place as each session finishes booting. Placeholders are dropped automatically when the real entry is registered (or the launch fails). Co-Authored-By: Claude Opus 4.7 --- src/CodeShellManager/MainWindow.xaml.cs | 296 ++++++++++++++++-- src/CodeShellManager/Models/AppState.cs | 7 + .../Views/SettingsWindow.xaml | 7 + .../Views/SettingsWindow.xaml.cs | 4 + 4 files changed, 280 insertions(+), 34 deletions(-) diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 79ee554..5934b78 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -46,6 +46,11 @@ public partial class MainWindow : Window // Sidebar items for dormant (asleep) sessions — kept here so RebuildSidebarOrder // can re-append them to the bottom of the list after rebuilding active items. private readonly Dictionary _dormantSidebarItems = []; + // Sidebar placeholders shown while a session is still launching (restore-on-startup). + // RebuildSidebarOrder weaves them into the live-session list in saved order so the + // user sees the full set of icons immediately, each with a "loading" indicator. + // Items are removed once LaunchSessionAsync registers the real sidebar item. + private readonly Dictionary _launchingSidebarItems = []; /// /// Per-session references to the run-related controls inside the terminal wrapper. /// Used by RefreshTerminalRunControls() to update the play button / chips strip @@ -224,19 +229,35 @@ private async void OnLoaded(object sender, RoutedEventArgs e) if (doRestore) { - // Launch live sessions first, then append dormant entries — keeps the - // "dormant always at the bottom" invariant that SleepSession and - // RebuildSidebarOrder enforce at runtime. - // Stagger consecutive claude launches: claude's CLI does an unlocked - // read-modify-write on ~/.claude.json at startup, so simultaneous - // boots can corrupt the user's profile. + // Build "launching" placeholder sidebar items for every live session up-front + // so the full list of icons appears immediately, with a loading indicator on + // each row until its real sidebar item replaces it. Dormant entries are added + // at the bottom (live ones get placeholders woven in by RebuildSidebarOrder). + foreach (var s in saved) + { + if (s.IsDormant) continue; + AddLaunchingSidebarItem(s); + } + foreach (var s in saved) + { + if (s.IsDormant) AddDormantSidebarItem(s); + } + // Render the staged placeholders now. RebuildSidebarOrder weaves them into + // the saved-order list (Resolve picks them when no live item exists yet) and + // applies the active group filter so off-group placeholders are hidden. + RebuildSidebarOrder(); + + // Launch live sessions sequentially. Stagger consecutive claude launches: + // claude's CLI does an unlocked read-modify-write on ~/.claude.json at startup, + // so simultaneous boots can corrupt the user's profile. + int staggerMs = _vm.Settings.ClaudeLaunchStaggerMs; bool lastWasClaude = false; foreach (var s in saved) { if (s.IsDormant) continue; bool isClaude = ClaudeSessionService.IsClaudeCommand(s.Command); - if (isClaude && lastWasClaude) - await Task.Delay(2000); + if (isClaude && lastWasClaude && staggerMs > 0) + await Task.Delay(staggerMs); try { await LaunchSessionAsync(s, restoring: true); } catch (Exception ex) { @@ -246,10 +267,6 @@ private async void OnLoaded(object sender, RoutedEventArgs e) } lastWasClaude = isClaude; } - foreach (var s in saved) - { - if (s.IsDormant) AddDormantSidebarItem(s); - } } else { @@ -410,13 +427,14 @@ private async Task LaunchAndFollowUpWorktreesAsync(ShellSession primary, IReadOn // Stagger consecutive claude launches for the same reason the boot path does // (see commit 59a7067): claude's CLI does an unlocked read-modify-write on // ~/.claude.json at startup, and back-to-back launches can corrupt it. + int staggerMs = _vm.Settings.ClaudeLaunchStaggerMs; string anchorId = primary.Id; bool lastWasClaude = ClaudeSessionService.IsClaudeCommand(primary.Command); foreach (var path in additionalPaths) { if (!System.IO.Directory.Exists(path)) continue; bool isClaude = ClaudeSessionService.IsClaudeCommand(primary.Command); - if (isClaude && lastWasClaude) await Task.Delay(2000); + if (isClaude && lastWasClaude && staggerMs > 0) await Task.Delay(staggerMs); var sibling = _sessionManager.CreateSession( System.IO.Path.GetFileName(path.TrimEnd('/', '\\')) ?? primary.Command, path, @@ -946,6 +964,9 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal "Launch Error", MessageBoxButton.OK, MessageBoxImage.Error); vm.Dispose(); _sessionManager.RemoveSession(session.Id); + // Drop any launching placeholder so it doesn't linger after a failed restore. + if (_launchingSidebarItems.Remove(session.Id)) + RebuildSidebarOrder(); return; } @@ -955,6 +976,9 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal // Build sidebar entry var sidebarItem = BuildSidebarItem(vm); _sessionUi[session.Id] = (webView, terminalWrapper, sidebarItem); + // Once the real sidebar item is registered, the launching placeholder for this + // session is no longer rendered by Resolve(); drop it so it doesn't leak. + _launchingSidebarItems.Remove(session.Id); _vm.RegisterSession(vm); // RebuildSidebarOrder applies the active group filter; the explicit call here ensures @@ -2625,11 +2649,36 @@ private void RebuildSidebarOrder() bool inlineMode = mode == Models.GroupDisplayMode.InlineHeaders && _sessionManager.Groups.Count > 0; + // Resolve a saved ShellSession to either its live sidebar item + VM, or a + // launching placeholder (vm == null). Returns null if the session is dormant + // or has no rendered representation yet (no UI built). + (Border item, SessionViewModel? vm)? Resolve(ShellSession s) + { + if (s.IsDormant) return null; + var liveVm = _vm.Sessions.FirstOrDefault(v => v.Id == s.Id); + if (liveVm != null && _sessionUi.TryGetValue(liveVm.Id, out var ui)) + return (ui.sidebarItem, liveVm); + if (_launchingSidebarItems.TryGetValue(s.Id, out var ph)) + return (ph, null); + return null; + } + + bool MatchesActiveGroupForSession(ShellSession s) + { + var activeGroupId = _vm.ActiveGroupId; + if (activeGroupId == null) return true; + if (activeGroupId == GroupFilter.Ungrouped) return string.IsNullOrEmpty(s.GroupId); + return s.GroupId == activeGroupId; + } + if (inlineMode) { // Ungrouped section first (only shown when it has members or there are groups). - var ungrouped = _vm.Sessions - .Where(s => string.IsNullOrEmpty(s.GroupId) && _sessionUi.ContainsKey(s.Id)) + var ungrouped = _sessionManager.Sessions + .Where(s => string.IsNullOrEmpty(s.GroupId) && !s.IsDormant) + .Select(s => Resolve(s)) + .Where(r => r.HasValue) + .Select(r => r!.Value) .ToList(); if (ungrouped.Count > 0) { @@ -2640,8 +2689,11 @@ private void RebuildSidebarOrder() // Each user group, in SortOrder. foreach (var g in _sessionManager.Groups.OrderBy(g => g.SortOrder)) { - var members = _vm.Sessions - .Where(s => s.GroupId == g.Id && _sessionUi.ContainsKey(s.Id)) + var members = _sessionManager.Sessions + .Where(s => s.GroupId == g.Id && !s.IsDormant) + .Select(s => Resolve(s)) + .Where(r => r.HasValue) + .Select(r => r!.Value) .ToList(); SidebarSessionList.Children.Add(BuildInlineGroupHeader(g, members.Count, g.IsExpanded)); if (g.IsExpanded) AppendSessionsWithClusters(members); @@ -2650,14 +2702,16 @@ private void RebuildSidebarOrder() else { // Flat list mode (None or FilterStrip). - var visibleSessions = new List(); - foreach (var vm in _vm.Sessions) + var visible = new List<(Border item, SessionViewModel? vm)>(); + foreach (var s in _sessionManager.Sessions) { - if (mode == Models.GroupDisplayMode.FilterStrip && !_vm.SessionMatchesActiveGroup(vm)) + if (s.IsDormant) continue; + if (mode == Models.GroupDisplayMode.FilterStrip && !MatchesActiveGroupForSession(s)) continue; - if (_sessionUi.ContainsKey(vm.Id)) visibleSessions.Add(vm); + var r = Resolve(s); + if (r.HasValue) visible.Add(r.Value); } - AppendSessionsWithClusters(visibleSessions); + AppendSessionsWithClusters(visible); } // Dormant entries always render at the bottom of the sidebar regardless of filter @@ -2671,33 +2725,38 @@ private void RebuildSidebarOrder() /// /// Appends a list of session sidebar items to , inserting - /// worktree cluster headers above runs of 2+ adjacent siblings (when enabled). + /// worktree cluster headers above runs of 2+ adjacent siblings (when enabled). Items with + /// a null VM are launching placeholders — they're rendered inline but skipped by the + /// cluster detector (their RepoRoot isn't known yet). /// - private void AppendSessionsWithClusters(List sessions) + private void AppendSessionsWithClusters(List<(Border item, SessionViewModel? vm)> items) { - var clusters = ComputeWorktreeClusters(sessions); + var clusters = ComputeWorktreeClusters(items); int clusterIdx = 0; - for (int i = 0; i < sessions.Count; i++) + for (int i = 0; i < items.Count; i++) { - var vm = sessions[i]; + var (item, vm) = items[i]; if (clusterIdx < clusters.Count && clusters[clusterIdx].start == i) { var (s, e, root) = clusters[clusterIdx]; int count = e - s + 1; - SidebarSessionList.Children.Add(BuildWorktreeClusterHeader(root, count, vm.AccentColor)); + string accent = vm?.AccentColor ?? "#89b4fa"; + SidebarSessionList.Children.Add(BuildWorktreeClusterHeader(root, count, accent)); clusterIdx++; } - SidebarSessionList.Children.Add(_sessionUi[vm.Id].sidebarItem); + SidebarSessionList.Children.Add(item); } } /// /// Returns the ranges of that should render under a worktree /// cluster header — runs of 2+ adjacent sessions sharing a RepoRoot. Empty when - /// the setting is off. + /// the setting is off. Launching placeholders (vm == null) have no RepoRoot, so they + /// act as cluster boundaries — adjacent live siblings around a placeholder won't be + /// detected as a cluster until the placeholder is replaced by a real sidebar item. /// private List<(int start, int end, string repoRoot)> ComputeWorktreeClusters( - IReadOnlyList visible) + IReadOnlyList<(Border item, SessionViewModel? vm)> visible) { var clusters = new List<(int, int, string)>(); if (!_vm.Settings.ShowWorktreeClusters) return clusters; @@ -2706,7 +2765,7 @@ private void AppendSessionsWithClusters(List sessions) string? runRoot = null; for (int i = 0; i < visible.Count; i++) { - string? root = visible[i].RepoRoot; + string? root = visible[i].vm?.RepoRoot; if (!string.IsNullOrEmpty(root) && root == runRoot) continue; if (runStart >= 0 && (i - runStart) >= 2) clusters.Add((runStart, i - 1, runRoot!)); @@ -3491,6 +3550,119 @@ private void AddDormantSidebarItem(ShellSession session) EmptyState.Visibility = Visibility.Collapsed; } + /// + /// Stages a "launching" placeholder sidebar entry for . The + /// placeholder is rendered by in saved order alongside + /// live items, and removed automatically when registers + /// the real sidebar item for the same session id. + /// + private void AddLaunchingSidebarItem(ShellSession session) + { + var item = BuildLaunchingSidebarItem(session); + _launchingSidebarItems[session.Id] = item; + EmptyState.Visibility = Visibility.Collapsed; + } + + /// + /// Builds a muted sidebar row visually similar to the dormant entry (so layout is + /// stable when it's later replaced) but with an animated dot indicating "launching". + /// No buttons — interaction is disabled until the session has actually started. + /// + private Border BuildLaunchingSidebarItem(ShellSession session) + { + string accentHex = GetAccentForSession(session); + var accentColor = (Color)ColorConverter.ConvertFromString(accentHex); + + var container = new Border + { + Margin = new Thickness(0, 2, 0, 2), + Background = Brushes.Transparent, + BorderBrush = Brushes.Transparent, + BorderThickness = new Thickness(2), + CornerRadius = new CornerRadius(6), + Tag = "launching:" + session.Id, + Opacity = 0.75, + ToolTip = "Launching…" + }; + + var inner = new Grid(); + inner.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(6) }); + inner.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + inner.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var stripe = new Border + { + Background = new SolidColorBrush(Color.FromArgb(0x80, accentColor.R, accentColor.G, accentColor.B)), + CornerRadius = new CornerRadius(4, 0, 0, 4), + Width = 6 + }; + Grid.SetColumn(stripe, 0); + + var textPanel = new StackPanel { Margin = new Thickness(8, 6, 4, 6) }; + + string displayName = string.IsNullOrWhiteSpace(session.Name) + ? (session.IsRemote + ? (string.IsNullOrWhiteSpace(session.SshHost) ? session.Command : session.SshHost) + : System.IO.Path.GetFileName(session.WorkingFolder.TrimEnd('/', '\\')) ?? session.Command) + : session.Name; + + var nameText = new TextBlock + { + Text = displayName, + Foreground = new SolidColorBrush(Color.FromRgb(0xa6, 0xad, 0xc8)), + FontSize = 13, + FontStyle = FontStyles.Italic, + TextTrimming = TextTrimming.CharacterEllipsis + }; + + string folderShort = session.IsRemote + ? (string.IsNullOrWhiteSpace(session.SshHost) ? "" : session.SshHost) + : (string.IsNullOrEmpty(session.WorkingFolder) + ? "" + : new System.IO.DirectoryInfo(session.WorkingFolder).Name); + + var folderText = new TextBlock + { + Text = "Launching… · " + folderShort, + Foreground = new SolidColorBrush(Color.FromRgb(0x6c, 0x70, 0x86)), + FontSize = 10, + Margin = new Thickness(0, 1, 0, 0), + TextTrimming = TextTrimming.CharacterEllipsis + }; + + textPanel.Children.Add(nameText); + textPanel.Children.Add(folderText); + Grid.SetColumn(textPanel, 1); + + // Pulsing dot using a DoubleAnimation on the dot's Opacity. Color matches the + // accent so it's clear which session this row represents. + var spinner = new Ellipse + { + Width = 8, + Height = 8, + Fill = new SolidColorBrush(accentColor), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(4, 0, 8, 0) + }; + var anim = new System.Windows.Media.Animation.DoubleAnimation + { + From = 0.25, + To = 1.0, + Duration = TimeSpan.FromMilliseconds(900), + AutoReverse = true, + RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever + }; + spinner.BeginAnimation(System.Windows.UIElement.OpacityProperty, anim); + Grid.SetColumn(spinner, 2); + + inner.Children.Add(stripe); + inner.Children.Add(textPanel); + inner.Children.Add(spinner); + container.Child = inner; + + return container; + } + private Border BuildDormantSidebarItem(ShellSession session) { string accentHex = GetAccentForSession(session); @@ -3850,6 +4022,7 @@ private void SettingsButton_Click(object sender, RoutedEventArgs e) var edited = dialog.EditedSettings; _vm.Settings.AutoRestoreSessions = edited.AutoRestoreSessions; _vm.Settings.AutoResumeClaude = edited.AutoResumeClaude; + _vm.Settings.ClaudeLaunchStaggerMs = edited.ClaudeLaunchStaggerMs; _vm.Settings.AutoFocusTerminalOnSelect = edited.AutoFocusTerminalOnSelect; _vm.Settings.ShowToastNotifications = edited.ShowToastNotifications; _vm.Settings.ShowNotificationSound = edited.ShowNotificationSound; @@ -4082,11 +4255,66 @@ protected override async void OnClosing(System.ComponentModel.CancelEventArgs e) if (_windowStateReady) _vm.UpdateWindowState(WindowState, Left, Top, Width, Height); await _vm.SaveStateAsync(); - foreach (var vm in _vm.Sessions.ToList()) - vm.Dispose(); + + var all = _vm.Sessions.ToList(); + + // Non-Claude sessions don't fight over ~/.claude.json — dispose them in parallel. + foreach (var vm in all) + { + if (!ClaudeSessionService.IsClaudeCommand(vm.Command)) + vm.Dispose(); + } + + // Claude rewrites ~/.claude.json on exit without locking, so two claude.exe + // processes flushing simultaneously can corrupt it. Dispose claude sessions one + // at a time, waiting for each process to *actually exit* before starting the next + // — a fixed time stagger isn't safe because claude's shutdown can take longer + // than the configured delay on slow disks. Cap each wait at 10s so a stuck claude + // doesn't hang application shutdown. + int postExitMs = _vm.Settings.ClaudeLaunchStaggerMs; + foreach (var vm in all) + { + if (!ClaudeSessionService.IsClaudeCommand(vm.Command)) continue; + await DisposeAndWaitForExitAsync(vm, timeoutMs: 10000); + // Small post-exit pause as belt-and-braces in case ~/.claude.json's write + // continues after the parent's shutdown signal but before its handles close. + if (postExitMs > 0) await Task.Delay(Math.Min(postExitMs, 1000)); + } + _db?.Close(); _db?.Dispose(); App.TrayIcon?.Dispose(); base.OnClosing(e); } + + /// + /// Signals the session's PTY to shut down and waits for its child process to actually + /// exit (or ms, whichever comes first), then fully + /// disposes the VM. Used for claude sessions on app close so consecutive + /// ~/.claude.json writes can't overlap. + /// + private static async Task DisposeAndWaitForExitAsync(SessionViewModel vm, int timeoutMs) + { + var pty = vm.Pty; + if (pty == null || !pty.IsRunning) + { + vm.Dispose(); + return; + } + + var tcs = new TaskCompletionSource(); + void OnExit() => tcs.TrySetResult(); + pty.Exited += OnExit; + try + { + // Dispose triggers ClosePseudoConsole, which signals the child to shut down. + // MonitorExitAsync (already running) will fire Exited once the process exits. + vm.Dispose(); + await Task.WhenAny(tcs.Task, Task.Delay(timeoutMs)); + } + finally + { + pty.Exited -= OnExit; + } + } } diff --git a/src/CodeShellManager/Models/AppState.cs b/src/CodeShellManager/Models/AppState.cs index 41b57a4..79aebf8 100644 --- a/src/CodeShellManager/Models/AppState.cs +++ b/src/CodeShellManager/Models/AppState.cs @@ -33,6 +33,13 @@ public class AppSettings { public bool AutoRestoreSessions { get; set; } = true; public bool AutoResumeClaude { get; set; } = true; + /// + /// Milliseconds to wait between consecutive Claude session launches (and shutdowns). + /// The Claude CLI performs an unlocked read-modify-write on ~/.claude.json on + /// startup and exit, so two claude.exe processes touching the file at the same time + /// can corrupt it. Spacing them out by ~2s avoids the race. 0 disables the stagger. + /// + public int ClaudeLaunchStaggerMs { get; set; } = 2000; public bool AutoFocusTerminalOnSelect { get; set; } = true; public bool ShowToastNotifications { get; set; } = false; public bool ShowNotificationSound { get; set; } = false; diff --git a/src/CodeShellManager/Views/SettingsWindow.xaml b/src/CodeShellManager/Views/SettingsWindow.xaml index 8328938..649a7ca 100644 --- a/src/CodeShellManager/Views/SettingsWindow.xaml +++ b/src/CodeShellManager/Views/SettingsWindow.xaml @@ -212,6 +212,13 @@ Margin="0,4,0,0" ToolTip="When clicking a session in the sidebar (or cycling with Ctrl+Tab), move keyboard focus into the terminal so you can start typing immediately."/> + + + + + + diff --git a/src/CodeShellManager/Views/SettingsWindow.xaml.cs b/src/CodeShellManager/Views/SettingsWindow.xaml.cs index c9e3ef3..a9c522b 100644 --- a/src/CodeShellManager/Views/SettingsWindow.xaml.cs +++ b/src/CodeShellManager/Views/SettingsWindow.xaml.cs @@ -24,6 +24,7 @@ public SettingsWindow(AppSettings current, SearchService? searchService = null) { AutoRestoreSessions = current.AutoRestoreSessions, AutoResumeClaude = current.AutoResumeClaude, + ClaudeLaunchStaggerMs = current.ClaudeLaunchStaggerMs, AutoFocusTerminalOnSelect = current.AutoFocusTerminalOnSelect, ShowToastNotifications = current.ShowToastNotifications, ShowNotificationSound = current.ShowNotificationSound, @@ -57,6 +58,7 @@ public SettingsWindow(AppSettings current, SearchService? searchService = null) DefaultFolderBox.Text = _edited.DefaultWorkingFolder; AutoRestoreCheck.IsChecked = _edited.AutoRestoreSessions; AutoResumeClaudeCheck.IsChecked = _edited.AutoResumeClaude; + ClaudeLaunchStaggerBox.Text = _edited.ClaudeLaunchStaggerMs.ToString(); AutoFocusTerminalOnSelectCheck.IsChecked = _edited.AutoFocusTerminalOnSelect; ShowToastCheck.IsChecked = _edited.ShowToastNotifications; ShowNotificationSoundCheck.IsChecked = _edited.ShowNotificationSound; @@ -143,6 +145,8 @@ private void Save_Click(object sender, RoutedEventArgs e) _edited.DefaultWorkingFolder = DefaultFolderBox.Text.Trim(); _edited.AutoRestoreSessions = AutoRestoreCheck.IsChecked == true; _edited.AutoResumeClaude = AutoResumeClaudeCheck.IsChecked == true; + if (int.TryParse(ClaudeLaunchStaggerBox.Text, out int staggerMs) && staggerMs >= 0) + _edited.ClaudeLaunchStaggerMs = staggerMs; _edited.AutoFocusTerminalOnSelect = AutoFocusTerminalOnSelectCheck.IsChecked == true; _edited.ShowToastNotifications = ShowToastCheck.IsChecked == true; _edited.ShowNotificationSound = ShowNotificationSoundCheck.IsChecked == true;