diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 4312db2..93685ca 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,42 @@ 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; + 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); + 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 +2695,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 +2708,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 +2731,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 +2771,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!)); @@ -2796,7 +2861,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 +2965,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); @@ -3477,6 +3556,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); @@ -3836,6 +4028,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; @@ -3845,6 +4038,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 +4076,7 @@ private void SettingsButton_Click(object sender, RoutedEventArgs e) } UpdateGroupStripVisibility(); - RebuildSidebarOrder(); + RebuildSidebarOrder(); // also re-runs RefreshTerminalLayout — picks up FilterGridByActiveGroup } } @@ -4066,11 +4261,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 5a99fce..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; @@ -43,6 +50,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 +134,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..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."/> + + + + + + @@ -239,6 +246,12 @@ + + = 0) + _edited.ClaudeLaunchStaggerMs = staggerMs; _edited.AutoFocusTerminalOnSelect = AutoFocusTerminalOnSelectCheck.IsChecked == true; _edited.ShowToastNotifications = ShowToastCheck.IsChecked == true; _edited.ShowNotificationSound = ShowNotificationSoundCheck.IsChecked == true; @@ -155,6 +163,8 @@ private void Save_Click(object sender, RoutedEventArgs e) var iconsModeTag = (SidebarActionIconsModeCombo.SelectedItem as ComboBoxItem)?.Tag?.ToString(); if (System.Enum.TryParse(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;