From 40cf709e353ce58e042c8e0943d9b26d80f78dc0 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Fri, 30 Oct 2026 08:31:34 +0100 Subject: [PATCH] fix(layout): keep a restarted or woken pane in its sidebar position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RegisterSession took the session's index in SessionManager.Sessions and inserted at that index in MainViewModel.Sessions. Those are different index spaces: the model also holds dormant sessions and ones the restore loop hasn't launched yet. Restart and wake re-insert a VM whose sidebar row never moved, so the pane drifted one slot down per such session above it — often all the way to the end of the grid. Insert after the live sessions that precede it in the model instead. The rule lives in the WPF-free SessionOrder so it is unit-tested; five of the seven new tests fail against the previous logic. Co-Authored-By: Claude Opus 5.5 --- src/CodeShellManager/Services/SessionOrder.cs | 29 +++++++ .../ViewModels/MainViewModel.cs | 15 ++-- .../SessionOrderTests.cs | 79 +++++++++++++++++++ 3 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 src/CodeShellManager/Services/SessionOrder.cs create mode 100644 tests/CodeShellManager.Tests/SessionOrderTests.cs diff --git a/src/CodeShellManager/Services/SessionOrder.cs b/src/CodeShellManager/Services/SessionOrder.cs new file mode 100644 index 0000000..70aa554 --- /dev/null +++ b/src/CodeShellManager/Services/SessionOrder.cs @@ -0,0 +1,29 @@ +namespace CodeShellManager.Services; + +/// WPF-free ordering rules between the sidebar model and the live session list. +public static class SessionOrder +{ + /// + /// Index at which a (re)launched session belongs in the live list + /// (MainViewModel.Sessions) so it keeps its place relative to the model order + /// (SessionManager.Sessions), or -1 if the session isn't in the model. + /// + /// + /// The two lists are different index spaces: the model also holds dormant sessions and + /// sessions the restore loop hasn't launched yet. Restart and wake re-insert a VM whose + /// sidebar row never moved, so the raw model index is wrong by one for every such session + /// above it — the pane drifted down the grid, often to the end, while the row stayed put. + /// The answer is the number of live sessions that precede it in the model. + /// + public static int LiveInsertIndex(IReadOnlyList modelOrder, IReadOnlyList liveIds, string id) + { + var live = new HashSet(liveIds); + int count = 0; + foreach (var modelId in modelOrder) + { + if (modelId == id) return count; + if (live.Contains(modelId)) count++; + } + return -1; + } +} diff --git a/src/CodeShellManager/ViewModels/MainViewModel.cs b/src/CodeShellManager/ViewModels/MainViewModel.cs index 18c44fb..28b2d1d 100644 --- a/src/CodeShellManager/ViewModels/MainViewModel.cs +++ b/src/CodeShellManager/ViewModels/MainViewModel.cs @@ -361,13 +361,14 @@ void Promote() }; } - // Mirror SessionManager order so insert-after-parent (CreateSession with afterSessionId) - // also lands the VM at the matching slot — otherwise the model order would be correct - // but the sidebar would still show the new entry at the bottom. - int idx = -1; - for (int i = 0; i < _sessionManager.Sessions.Count; i++) - if (_sessionManager.Sessions[i].Id == vm.Id) { idx = i; break; } - if (idx >= 0 && idx <= Sessions.Count) Sessions.Insert(idx, vm); + // Mirror SessionManager order so insert-after-parent (CreateSession with afterSessionId), + // restart and wake all land the VM at the slot matching its sidebar row. The model + // index can't be used directly — see SessionOrder.LiveInsertIndex. + int idx = SessionOrder.LiveInsertIndex( + _sessionManager.Sessions.Select(s => s.Id).ToList(), + Sessions.Select(s => s.Id).ToList(), + vm.Id); + if (idx >= 0) Sessions.Insert(idx, vm); else Sessions.Add(vm); ActiveSession = vm; _ = SaveStateAsync(); diff --git a/tests/CodeShellManager.Tests/SessionOrderTests.cs b/tests/CodeShellManager.Tests/SessionOrderTests.cs new file mode 100644 index 0000000..309e6ac --- /dev/null +++ b/tests/CodeShellManager.Tests/SessionOrderTests.cs @@ -0,0 +1,79 @@ +using CodeShellManager.Services; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// Where a (re)launched session's VM goes in MainViewModel.Sessions (live only), +/// given its position in SessionManager.Sessions (live + dormant, all groups). +/// Restart and wake both re-insert a VM whose sidebar row never moved; using the raw +/// model index against the live list pushed the pane one slot further down for every +/// dormant or not-yet-relaunched session above it. +/// +public class SessionOrderTests +{ + [Fact] + public void Restart_with_a_dormant_session_above_lands_in_its_sidebar_position() + { + // Sidebar: A, D(dormant), B, C. Restarting B: the live list is [A, C] meanwhile. + string[] model = ["A", "D", "B", "C"]; + string[] live = ["A", "C"]; + + Assert.Equal(1, SessionOrder.LiveInsertIndex(model, live, "B")); + } + + [Fact] + public void Restart_of_the_first_session_goes_first() + { + string[] model = ["D", "A", "B"]; + string[] live = ["B"]; + + Assert.Equal(0, SessionOrder.LiveInsertIndex(model, live, "A")); + } + + [Fact] + public void Restart_of_the_last_session_goes_last() + { + string[] model = ["A", "D1", "B", "D2", "C"]; + string[] live = ["A", "B"]; + + Assert.Equal(2, SessionOrder.LiveInsertIndex(model, live, "C")); + } + + [Fact] + public void Several_dormant_sessions_above_do_not_accumulate_an_offset() + { + string[] model = ["D1", "D2", "D3", "A", "B", "C"]; + string[] live = ["A", "C"]; + + Assert.Equal(1, SessionOrder.LiveInsertIndex(model, live, "B")); + } + + [Fact] + public void Sessions_still_launching_above_are_skipped() + { + // Restore loop: A and B are in the model but not yet live when C registers. + string[] model = ["A", "B", "C", "E"]; + string[] live = ["E"]; + + Assert.Equal(0, SessionOrder.LiveInsertIndex(model, live, "C")); + } + + [Fact] + public void With_nothing_dormant_it_matches_the_model_index() + { + string[] model = ["A", "B", "C"]; + string[] live = ["A", "C"]; + + Assert.Equal(1, SessionOrder.LiveInsertIndex(model, live, "B")); + } + + [Fact] + public void A_session_missing_from_the_model_appends() + { + string[] model = ["A", "B"]; + string[] live = ["A", "B"]; + + Assert.Equal(-1, SessionOrder.LiveInsertIndex(model, live, "X")); + } +}