From 6a0443910a0ca9585ebd72cd46348c0ead2936c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Aslo-=C3=98stergaard?= Date: Wed, 30 Sep 2026 12:26:42 +0200 Subject: [PATCH] fix(sidebar): sleeping sessions obey the group filter Dormant rows were appended unconditionally at the very bottom of the sidebar, whatever the display mode or active group tab. The comment called that "kept reachable", but on a filtered tab it read as a leak: every sleeping session from every group, under a tab that was supposed to show one. Placement now follows the display mode: - FilterStrip: dormant rows still trail the live ones, but only those matching ActiveGroupId. "All" still lists every one, so a sleeping session is never more than a click away. - InlineHeaders: each dormant row sits at the end of its own group section and counts toward that header's badge, so it collapses with the group. Ungrouped now also appears when its only members are asleep. - None: unchanged. Rows are resolved by walking SessionManager.Sessions rather than the _dormantSidebarItems dictionary, so sleeping rows follow drag-reorder the same way live ones do instead of insertion order. AddDormantSidebarItem now only registers the row; RebuildSidebarOrder is the single placer, because where a row goes is a filter/mode decision. SleepSession and the wake-failure path call it (in place of a bare RefreshTerminalLayout); the other callers already did. Two further holes, found in review: - Deleting a dormant row removed its Border in place and never rebuilt. Harmless while dormant rows sat outside the sections, but inline headers now count them, so it left "Work (3)" over two rows, or an empty header when it was the section's last occupant. It rebuilds now. - In InlineHeaders mode a dormant session whose GroupId names a group that no longer exists rendered nowhere, while still suppressing EmptyState. That cannot arise in-app (RemoveGroup clears GroupId), but ImportExportService deserializes an arbitrary AppState and nothing reconciles orphan ids, so it now buckets as ungrouped. Live sessions have the same gap; closing it properly means normalizing on load, and is left for a separate change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NsYm9iLc2aRnRDfV2uZRQX --- CLAUDE.md | 5 +- src/CodeShellManager/MainWindow.xaml.cs | 81 +++++++++++++++++++++---- 2 files changed, 71 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cda4cd7..f6717a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -576,13 +576,14 @@ Sessions can be put to sleep instead of closed — the PTY is torn down but the **UI:** - 💤 button appears in both the sidebar action panel (next to ✕) and the terminal toolbar. -- Dormant entries render at the bottom of the sidebar with a muted (55% opacity) appearance. Clicking anywhere on a dormant entry wakes it; the small ✕ on a dormant entry permanently deletes (with confirmation). +- Dormant entries render with a muted (55% opacity) appearance, trailing the live rows. Clicking anywhere on a dormant entry wakes it; the small ✕ on a dormant entry permanently deletes (with confirmation). +- **Dormant rows obey the group filter too.** In `FilterStrip` mode a group tab lists only its own sleeping sessions ("All" and `GroupDisplayMode.None` list every one); in `InlineHeaders` mode each sleeping row sits at the end of its own group section and counts toward that header's badge, so it collapses with the group. They used to be appended unconditionally at the very bottom — the comment there called it "kept reachable", but on a filtered tab it read as a leak, and a sleeping session already stays reachable one click away on "All". **Implementation (`MainWindow.xaml.cs`):** - `SleepSession(vm)` — sets `session.IsDormant = true`, removes from `_vm.Sessions` directly (bypassing `CloseCommand` so the `ShellSession` is **not** removed from `SessionManager`), disposes the VM, and calls `AddDormantSidebarItem(session)`. - `WakeSessionAsync(session)` — clears `IsDormant`, removes the dormant sidebar entry, then `await LaunchSessionAsync(session, restoring: true)`. On launch failure it restores the dormant entry. - `BuildDormantSidebarItem(ShellSession)` — builds a static (no-VM) sidebar Border with muted accent stripe + 💤 icon. Click handler resolves to `WakeSessionAsync`. -- Dormant entries are tracked in `_dormantSidebarItems: Dictionary` so `RebuildSidebarOrder` (called after drag-reorder) can re-append them at the bottom. +- Dormant entries are tracked in `_dormantSidebarItems: Dictionary`. `AddDormantSidebarItem` only *registers* the row — it deliberately does **not** add it to the visual tree, because its placement depends on the display mode and the active filter. `RebuildSidebarOrder` is the single placer, and it walks `_sessionManager.Sessions` (not the dictionary) so sleeping rows follow drag-reorder like live ones. Every caller that stages a dormant row must run `RebuildSidebarOrder` afterwards — `SleepSession` and the wake-failure path do so in place of their old bare `RefreshTerminalLayout`. - `OnLoaded` partitions saved sessions: dormant ones go through `AddDormantSidebarItem`; live ones through `LaunchSessionAsync`. - The empty-state placeholder hides whenever `_vm.Sessions.Count > 0` **or** `_dormantSidebarItems.Count > 0`. diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index d6f6167..8a46e9a 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -3754,8 +3754,38 @@ bool MatchesActiveGroupForSession(ShellSession s) return s.GroupId == activeGroupId; } + // Dormant rows matching a predicate, in SessionManager order — so sleeping rows + // follow the same drag-reorder as live ones instead of _dormantSidebarItems' + // insertion order. They have no VM, so they render as plain rows. + List DormantWhere(Func predicate) + { + var list = new List(); + foreach (var s in _sessionManager.Sessions) + { + if (!s.IsDormant || !predicate(s)) continue; + if (_dormantSidebarItems.TryGetValue(s.Id, out var item)) list.Add(item); + } + return list; + } + + void AppendDormant(List rows) + { + foreach (var row in rows) SidebarSessionList.Children.Add(row); + } + if (inlineMode) { + // A GroupId naming a group that no longer exists buckets as ungrouped, so the row + // still renders somewhere. RemoveGroup clears GroupId, so this cannot arise in-app + // — but ImportExportService deserializes an arbitrary AppState and nothing + // reconciles orphan ids, and a dormant row that renders nowhere still suppresses + // EmptyState via _dormantSidebarItems.Count, which is an unreachable session. + // (Live sessions have the same gap below; fixing that means normalizing on load.) + var knownGroupIds = new HashSet( + _sessionManager.Groups.Select(g => g.Id), StringComparer.Ordinal); + bool IsUngrouped(ShellSession s) => + string.IsNullOrEmpty(s.GroupId) || !knownGroupIds.Contains(s.GroupId); + // Ungrouped section first (only shown when it has members or there are groups). var ungrouped = _sessionManager.Sessions .Where(s => string.IsNullOrEmpty(s.GroupId) && !s.IsDormant) @@ -3763,11 +3793,17 @@ bool MatchesActiveGroupForSession(ShellSession s) .Where(r => r.HasValue) .Select(r => r!.Value) .ToList(); - if (ungrouped.Count > 0) + var ungroupedDormant = DormantWhere(IsUngrouped); + if (ungrouped.Count + ungroupedDormant.Count > 0) { bool ungroupedExpanded = _vm.Settings.UngroupedSectionExpanded; - SidebarSessionList.Children.Add(BuildInlineGroupHeader(null, ungrouped.Count, ungroupedExpanded)); - if (ungroupedExpanded) AppendSessionsWithClusters(ungrouped); + SidebarSessionList.Children.Add(BuildInlineGroupHeader( + null, ungrouped.Count + ungroupedDormant.Count, ungroupedExpanded)); + if (ungroupedExpanded) + { + AppendSessionsWithClusters(ungrouped); + AppendDormant(ungroupedDormant); + } } // Each user group, in SortOrder. foreach (var g in _sessionManager.Groups.OrderBy(g => g.SortOrder)) @@ -3778,8 +3814,14 @@ bool MatchesActiveGroupForSession(ShellSession s) .Where(r => r.HasValue) .Select(r => r!.Value) .ToList(); - SidebarSessionList.Children.Add(BuildInlineGroupHeader(g, members.Count, g.IsExpanded)); - if (g.IsExpanded) AppendSessionsWithClusters(members); + var dormantMembers = DormantWhere(s => s.GroupId == g.Id); + SidebarSessionList.Children.Add(BuildInlineGroupHeader( + g, members.Count + dormantMembers.Count, g.IsExpanded)); + if (g.IsExpanded) + { + AppendSessionsWithClusters(members); + AppendDormant(dormantMembers); + } } } else @@ -3795,13 +3837,15 @@ bool MatchesActiveGroupForSession(ShellSession s) if (r.HasValue) visible.Add(r.Value); } AppendSessionsWithClusters(visible); + + // Dormant entries still trail the live ones, but they obey the same group + // filter: a filtered tab that went on listing every sleeping session read as a + // leak, not as "kept reachable". "All" — and GroupDisplayMode.None, which has + // no strip to filter by — still shows every one of them. + AppendDormant(DormantWhere(s => + mode != Models.GroupDisplayMode.FilterStrip || MatchesActiveGroupForSession(s))); } - // Dormant entries always render at the bottom of the sidebar regardless of filter - // or display mode so they remain reachable (and a user filtering by category isn't - // surprised by missing entries). - foreach (var item in _dormantSidebarItems.Values) - SidebarSessionList.Children.Add(item); UpdateSidebarActiveState(); RefreshTerminalLayout(); } @@ -5065,7 +5109,9 @@ private void SleepSession(SessionViewModel vm) AddDormantSidebarItem(session); - RefreshTerminalLayout(); + // RebuildSidebarOrder, not a bare RefreshTerminalLayout: it is what places the new + // dormant row (and it ends in RefreshTerminalLayout itself). + RebuildSidebarOrder(); UpdateAlertBadge(); EmptyState.Visibility = _vm.Sessions.Count == 0 && _dormantSidebarItems.Count == 0 ? Visibility.Visible : Visibility.Collapsed; @@ -5096,17 +5142,23 @@ private async Task WakeSessionAsync(ShellSession session) // Restore the dormant entry so the user doesn't lose access to the session session.IsDormant = true; AddDormantSidebarItem(session); + RebuildSidebarOrder(); MessageBox.Show($"Failed to wake '{session.Name}': {ex.Message}", "Wake Error", MessageBoxButton.OK, MessageBoxImage.Warning); } _ = _vm.SaveStateAsync(); } + /// + /// Builds and registers the muted sidebar row for a dormant session. The row is NOT + /// added to the visual tree here: where it belongs — which group section, or whether it + /// shows at all under the active filter — is 's call. + /// Every caller must run that once it has finished staging rows. + /// private void AddDormantSidebarItem(ShellSession session) { var item = BuildDormantSidebarItem(session); _dormantSidebarItems[session.Id] = item; - SidebarSessionList.Children.Add(item); EmptyState.Visibility = Visibility.Collapsed; } @@ -5296,9 +5348,12 @@ private Border BuildDormantSidebarItem(ShellSession session) "Delete session", MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No); if (result != MessageBoxResult.Yes) return; - SidebarSessionList.Children.Remove(container); _dormantSidebarItems.Remove(session.Id); _sessionManager.RemoveSession(session.Id); + // Rebuild rather than removing the Border in place: inline group headers count + // dormant members, so an in-place removal leaves a stale badge over the section + // — or an empty header, when this was its last occupant. + RebuildSidebarOrder(); if (_vm.Sessions.Count == 0 && _dormantSidebarItems.Count == 0) EmptyState.Visibility = Visibility.Visible; _ = _vm.SaveStateAsync();