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"));
+ }
+}