From d4cab7ce1445c6c68c81baa5ebe9137656598308 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Aslo-=C3=98stergaard?= Date: Wed, 13 May 2026 15:12:08 +0200 Subject: [PATCH 1/3] feat(groups): scope grid + remember layout per group (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two settings (both default on) so groups can own the visible terminal panes and their layout, addressing #30: - FilterGridByActiveGroup: when a group is the effective filter, the multi-pane grid only shows that group's sessions. In FilterStrip mode the filter is the selected tab; in InlineHeaders mode it's derived from the active session's group (no tab strip exists there). - PerGroupLayout + AppState.GroupLayouts: each group remembers its own Layout (Single / 2x2 / etc.). Crossing a group boundary saves the old group's layout (seeded if absent) and restores the new group's. Sessions outside the active group stay live — PTY, indexing, and alerts keep running; they're just not rendered until the filter swings back. Co-Authored-By: Claude Opus 4.7 --- src/CodeShellManager/MainWindow.xaml.cs | 22 +++- src/CodeShellManager/Models/AppState.cs | 20 ++++ .../ViewModels/MainViewModel.cs | 102 +++++++++++++++++- .../Views/SettingsWindow.xaml | 6 ++ .../Views/SettingsWindow.xaml.cs | 6 ++ 5 files changed, 152 insertions(+), 4 deletions(-) diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 4312db2..79ee554 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -2796,7 +2796,17 @@ private void RefreshTerminalLayout() TerminalGrid.RowDefinitions.Clear(); TerminalGrid.ColumnDefinitions.Clear(); - var sessions = _vm.Sessions.ToList(); + // When FilterGridByActiveGroup is on, restrict the panes to the effective group: + // FilterStrip mode → the explicitly selected tab (ActiveGroupId) + // InlineHeaders mode → the ActiveSession's group (no tab strip exists, so the + // focused session is the implicit "current group" selector) + // In None mode there is no group concept, so no filter applies. + IEnumerable source = _vm.Sessions; + if (_vm.Settings.FilterGridByActiveGroup && _vm.EffectiveActiveGroupId != null) + { + source = source.Where(_vm.SessionMatchesEffectiveGroup); + } + var sessions = source.ToList(); if (sessions.Count == 0) { EmptyState.Visibility = Visibility.Visible; @@ -2890,7 +2900,11 @@ private void RefreshTerminalLayout() default: // Single { - var target = _vm.ActiveSession ?? sessions.FirstOrDefault(); + // If the active session is filtered out by the group filter, fall back + // to the first visible session so the pane doesn't show a hidden tab. + var target = (_vm.ActiveSession != null && sessions.Contains(_vm.ActiveSession)) + ? _vm.ActiveSession + : sessions.FirstOrDefault(); if (target != null && _sessionUi.TryGetValue(target.Id, out var ui)) { TerminalGrid.Children.Add(ui.terminalWrapper); @@ -3845,6 +3859,8 @@ private void SettingsButton_Click(object sender, RoutedEventArgs e) _vm.Settings.ShowGitBranch = edited.ShowGitBranch; _vm.Settings.ShowGroupsTab = edited.ShowGroupsTab; _vm.Settings.GroupDisplayMode = edited.GroupDisplayMode; + _vm.Settings.FilterGridByActiveGroup = edited.FilterGridByActiveGroup; + _vm.Settings.PerGroupLayout = edited.PerGroupLayout; _vm.Settings.SidebarActionIconsMode = edited.SidebarActionIconsMode; _vm.Settings.ShowWorktreeClusters = edited.ShowWorktreeClusters; _vm.Settings.SearchCollapseAfterNavigate = edited.SearchCollapseAfterNavigate; @@ -3881,7 +3897,7 @@ private void SettingsButton_Click(object sender, RoutedEventArgs e) } UpdateGroupStripVisibility(); - RebuildSidebarOrder(); + RebuildSidebarOrder(); // also re-runs RefreshTerminalLayout — picks up FilterGridByActiveGroup } } diff --git a/src/CodeShellManager/Models/AppState.cs b/src/CodeShellManager/Models/AppState.cs index 5a99fce..41b57a4 100644 --- a/src/CodeShellManager/Models/AppState.cs +++ b/src/CodeShellManager/Models/AppState.cs @@ -43,6 +43,19 @@ public class AppSettings /// Authoritative grouping UI selector. Replaces the legacy boolean. public GroupDisplayMode GroupDisplayMode { get; set; } = GroupDisplayMode.FilterStrip; /// + /// In FilterStrip mode with an active group filter, restrict the terminal grid + /// (multi-pane layouts) to sessions belonging to that group. The sidebar already + /// hides non-matching rows; with this on, the panes match. Off = the grid keeps + /// showing every live session regardless of group filter. + /// + public bool FilterGridByActiveGroup { get; set; } = true; + /// + /// Remember the grid layout (Single / TwoByTwo / etc.) separately per group so each + /// group restores its own layout when selected. See + /// for the backing store. + /// + public bool PerGroupLayout { get; set; } = true; + /// /// Legacy flag — kept for back-compat with older state.json files. When deserialized /// as false on a state that still has GroupDisplayMode at its default, the loader /// migrates the mode to None. Newer code paths read GroupDisplayMode instead. @@ -114,6 +127,13 @@ public class AppState public List Sessions { get; set; } = []; public List Groups { get; set; } = []; public string LastLayout { get; set; } = "Single"; + /// + /// Per-group grid layouts when is on. + /// Key = group Id, GroupFilter.Ungrouped, or "__ALL__" for the + /// no-filter view. Value = name. Missing + /// keys fall back to . + /// + public Dictionary GroupLayouts { get; set; } = new(); public AppSettings Settings { get; set; } = new(); // Window state persistence diff --git a/src/CodeShellManager/ViewModels/MainViewModel.cs b/src/CodeShellManager/ViewModels/MainViewModel.cs index f27beb7..e1c8507 100644 --- a/src/CodeShellManager/ViewModels/MainViewModel.cs +++ b/src/CodeShellManager/ViewModels/MainViewModel.cs @@ -16,6 +16,8 @@ public enum LayoutMode { Single, TwoColumn, ThreeColumn, TwoByTwo, TwoRow, FourC public static class GroupFilter { public const string Ungrouped = "__UNGROUPED__"; + /// Key used in for the no-filter ("All") view. + public const string AllKey = "__ALL__"; } public partial class MainViewModel : ObservableObject @@ -38,6 +40,15 @@ public partial class MainViewModel : ObservableObject /// [ObservableProperty] private string? _activeGroupId; + /// Guard so layout assignments driven by state-load or per-group restore don't write back to GroupLayouts. + private bool _suppressLayoutPersist; + + /// Tracks the previous effective group key so the per-group handler can save the old slot before switching. + private string _lastEffectiveLayoutKey = GroupFilter.AllKey; + + /// Key used to look up the current view's layout in . + private string CurrentLayoutKey => EffectiveActiveGroupId ?? GroupFilter.AllKey; + /// IDs of sessions currently in the multi-select set (in addition to ActiveSession). public HashSet SelectedSessionIds { get; } = new(); @@ -96,6 +107,36 @@ public bool SessionMatchesActiveGroup(SessionViewModel vm) return vm.GroupId == ActiveGroupId; } + /// + /// The group the main grid is currently scoped to, accounting for display mode: + /// FilterStrip = ActiveGroupId (explicit tab); InlineHeaders = the ActiveSession's + /// group (there's no tab strip, so the focused session is the implicit selector); + /// None = null (no group concept). Returns for + /// sessions without a group, or the group id, or null for "no filter". + /// + public string? EffectiveActiveGroupId + { + get + { + var mode = Settings.GroupDisplayMode; + if (mode == Models.GroupDisplayMode.FilterStrip) return ActiveGroupId; + if (mode == Models.GroupDisplayMode.InlineHeaders && ActiveSession != null) + return string.IsNullOrEmpty(ActiveSession.GroupId) + ? GroupFilter.Ungrouped + : ActiveSession.GroupId; + return null; + } + } + + /// Like but uses . + public bool SessionMatchesEffectiveGroup(SessionViewModel vm) + { + var eff = EffectiveActiveGroupId; + if (eff == null) return true; + if (eff == GroupFilter.Ungrouped) return string.IsNullOrEmpty(vm.GroupId); + return vm.GroupId == eff; + } + public bool IsSelected(string sessionId) => SelectedSessionIds.Contains(sessionId); public void ClearSelection() @@ -160,7 +201,12 @@ public async Task LoadStateAsync() { _appState = await _stateService.LoadAsync(); _sessionManager.LoadFromState(_appState); - Layout = Enum.TryParse(_appState.LastLayout, out var lm) ? lm : LayoutMode.Single; + _suppressLayoutPersist = true; + try + { + Layout = Enum.TryParse(_appState.LastLayout, out var lm) ? lm : LayoutMode.Single; + } + finally { _suppressLayoutPersist = false; } // Legacy migration: pre-enum installs persisted "ShowGroupsTab=false" to hide // the strip. Translate to the new enum on first load with the new code. @@ -317,4 +363,58 @@ public void MoveSession(string sessionId, int newIndex) if (cur != newIndex) Sessions.Move(cur, newIndex); _ = SaveStateAsync(); } + + partial void OnLayoutChanged(LayoutMode value) + { + if (_suppressLayoutPersist) return; + if (!Settings.PerGroupLayout) return; + _appState.GroupLayouts[CurrentLayoutKey] = value.ToString(); + } + + partial void OnActiveGroupIdChanged(string? oldValue, string? newValue) => HandleEffectiveGroupChanged(); + + partial void OnActiveSessionChanged(SessionViewModel? oldValue, SessionViewModel? newValue) + { + // ActiveSession only contributes to the effective group in InlineHeaders mode — + // in other modes its change doesn't move us between groups. + if (Settings.GroupDisplayMode != Models.GroupDisplayMode.InlineHeaders) return; + HandleEffectiveGroupChanged(); + } + + /// + /// Called whenever the effective group filter may have changed (ActiveGroupId in + /// FilterStrip mode, or ActiveSession in InlineHeaders mode). Saves the old group's + /// layout if not already persisted, then restores the new group's saved layout if any. + /// + private void HandleEffectiveGroupChanged() + { + if (!Settings.PerGroupLayout) return; + string newKey = EffectiveActiveGroupId ?? GroupFilter.AllKey; + if (newKey == _lastEffectiveLayoutKey) return; + string oldKey = _lastEffectiveLayoutKey; + _lastEffectiveLayoutKey = newKey; + + // Seed the old key with the current layout in case the user never explicitly + // changed it there — otherwise round-tripping back to that group would miss. + bool seeded = false; + if (!_appState.GroupLayouts.ContainsKey(oldKey)) + { + _appState.GroupLayouts[oldKey] = Layout.ToString(); + seeded = true; + } + + bool layoutSwitched = false; + if (_appState.GroupLayouts.TryGetValue(newKey, out var s) + && Enum.TryParse(s, out var lm) + && lm != Layout) + { + _suppressLayoutPersist = true; + try { Layout = lm; } + finally { _suppressLayoutPersist = false; } + layoutSwitched = true; + } + + if (seeded || layoutSwitched) + _ = SaveStateAsync(); + } } diff --git a/src/CodeShellManager/Views/SettingsWindow.xaml b/src/CodeShellManager/Views/SettingsWindow.xaml index 0cda35a..8328938 100644 --- a/src/CodeShellManager/Views/SettingsWindow.xaml +++ b/src/CodeShellManager/Views/SettingsWindow.xaml @@ -239,6 +239,12 @@ + + (iconsModeTag, out var newIconsMode)) _edited.SidebarActionIconsMode = newIconsMode; + _edited.FilterGridByActiveGroup = FilterGridByActiveGroupCheck.IsChecked == true; + _edited.PerGroupLayout = PerGroupLayoutCheck.IsChecked == true; _edited.ImportWindowsTerminalProfiles = ImportWindowsTerminalProfilesCheck.IsChecked == true; _edited.SearchCollapseAfterNavigate = SearchCollapseAfterNavigateCheck.IsChecked == true; _edited.AnthropicApiKey = ApiKeyBox.Password; 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 2/3] 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; From 3fbb1240cbfdb21aa1fb1c0948e8b0593c915293 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Wed, 13 May 2026 16:33:18 +0200 Subject: [PATCH 3/3] =?UTF-8?q?perf:=20precompute=20Sessions=20dict=20in?= =?UTF-8?q?=20RebuildSidebarOrder=20to=20drop=20O(n=C2=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve(ShellSession) was doing _vm.Sessions.FirstOrDefault(v => v.Id == s.Id) per saved session. Snapshot _vm.Sessions into a dictionary at the top of the method so each Resolve call is O(1). RebuildSidebarOrder fires on many UI events (filter change, membership change, drag-reorder, launch) so the saved- list × live-list scan was the right thing to flatten. Addresses Copilot review feedback on PR #35. Co-Authored-By: Claude Opus 4.7 --- src/CodeShellManager/MainWindow.xaml.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 5934b78..93685ca 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -2649,14 +2649,20 @@ private void RebuildSidebarOrder() bool inlineMode = mode == Models.GroupDisplayMode.InlineHeaders && _sessionManager.Groups.Count > 0; + // Snapshot _vm.Sessions into a dictionary up front so Resolve is O(1) per call. + // RebuildSidebarOrder fires on group filter / membership / drag-reorder / launch, + // so the previous FirstOrDefault-in-a-loop was O(n²) for the saved-session list. + var liveById = new Dictionary(_vm.Sessions.Count); + foreach (var v in _vm.Sessions) liveById[v.Id] = v; + // 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)) + if (liveById.TryGetValue(s.Id, out var liveVm) + && _sessionUi.TryGetValue(liveVm.Id, out var ui)) return (ui.sidebarItem, liveVm); if (_launchingSidebarItems.TryGetValue(s.Id, out var ph)) return (ph, null);