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;