Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Border>` so `RebuildSidebarOrder` (called after drag-reorder) can re-append them at the bottom.
- Dormant entries are tracked in `_dormantSidebarItems: Dictionary<string, Border>`. `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`.

Expand Down
81 changes: 68 additions & 13 deletions src/CodeShellManager/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3754,20 +3754,56 @@ 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<Border> DormantWhere(Func<ShellSession, bool> predicate)
{
var list = new List<Border>();
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<Border> 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<string>(
_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)
.Select(s => Resolve(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))
Expand All @@ -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
Expand All @@ -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();
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}

/// <summary>
/// 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 <see cref="RebuildSidebarOrder"/>'s call.
/// Every caller must run that once it has finished staging rows.
/// </summary>
private void AddDormantSidebarItem(ShellSession session)
{
var item = BuildDormantSidebarItem(session);
_dormantSidebarItems[session.Id] = item;
SidebarSessionList.Children.Add(item);
EmptyState.Visibility = Visibility.Collapsed;
}

Expand Down Expand Up @@ -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();
Expand Down