Skip to content
This repository was archived by the owner on May 24, 2026. It is now read-only.

Commit 50cfbe4

Browse files
PureWeenCopilot
andauthored
perf: cap keep-alive slots to 5 MRU sessions (#482)
## Problem With 55+ active sessions, the keep-alive-slot pattern renders ALL session views in the DOM simultaneously: - **38,233 DOM nodes** total - **37,229 nodes** in keep-alive slots alone - **5,396ms click latency** measured when selecting a session - **612 chat messages** with full Blazor component trees (copy buttons, SVGs, action controls) PR #476 added ~12 `CopyToClipboardButton` instances per message (~34 extra DOM nodes each), pushing per-slot cost past the tipping point for the existing keep-alive architecture. ## Fix Track the 5 most recently accessed sessions via an LRU list (`LinkedList<string>` + `HashSet<string>`). Only render `KeepAliveSession` slots for MRU sessions + the currently expanded session. Evicted slots are destroyed and recreated on next access. **No data loss:** Session state lives in `CopilotService`, not the DOM. The only UX cost is losing scroll position and draft text on evicted sessions. ## Expected improvement - DOM nodes: **37K → ~3.4K** (~90% reduction) - Click latency: **5.4s → <100ms** (based on pre-#476 measurements) - Memory: proportional reduction in Blazor component instances ## Testing - All 3,111 tests pass - `TouchMru()` called at every `expandedSession` assignment site (14 locations) - `ShouldRenderSlot()` guards keep-alive foreach with MRU + current session check --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 58d89db commit 50cfbe4

3 files changed

Lines changed: 105 additions & 4 deletions

File tree

PolyPilot/Components/KeepAliveSession.razor

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@
1313
@code {
1414
[Parameter] public string Id { get; set; } = "";
1515
[Parameter] public bool IsVisible { get; set; }
16+
[Parameter] public bool HasContent { get; set; } = true;
1617
[Parameter] public bool WarmWhenHidden { get; set; }
1718
[Parameter] public int HiddenWarmIntervalMs { get; set; } = 250;
1819
[Parameter] public RenderFragment ChildContent { get; set; } = default!;
1920
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }
2021

2122
private bool _prevVisible;
23+
private bool _prevHasContent = true;
2224
private long _lastHiddenWarmRenderAt;
2325

2426
protected override bool ShouldRender()
@@ -31,6 +33,13 @@
3133
return true;
3234
}
3335

36+
// Always render if content availability changed (mounting/unmounting ExpandedSessionView)
37+
if (HasContent != _prevHasContent)
38+
{
39+
_prevHasContent = HasContent;
40+
return true;
41+
}
42+
3443
if (IsVisible)
3544
return true;
3645

@@ -49,6 +58,10 @@
4958

5059
protected override void OnAfterRender(bool firstRender)
5160
{
52-
if (firstRender) _prevVisible = IsVisible;
61+
if (firstRender)
62+
{
63+
_prevVisible = IsVisible;
64+
_prevHasContent = HasContent;
65+
}
5366
}
5467
}

PolyPilot/Components/Pages/Dashboard.razor

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,14 +152,19 @@
152152
}
153153
</div>
154154
}
155-
@* Keep-alive: render all active sessions, JS owns 'active' class for instant switching *@
155+
@* Keep-alive: render slot divs for all sessions (JS needs them for instant CSS toggle),
156+
but only populate expensive ExpandedSessionView content for MRU/expanded/processing sessions *@
156157
@foreach (var session in sessions)
157158
{
159+
var renderContent = ShouldRenderSlot(session.Name);
158160
<KeepAliveSession Id="@($"slot-{session.Name.Replace(" ", "-")}")"
159161
IsVisible="@(session.Name == expandedSession)"
160-
WarmWhenHidden="@(completedSessions.Contains(session.Name) || (session.IsProcessing && (streamingBySession.ContainsKey(session.Name) || currentToolBySession.ContainsKey(session.Name) || activityBySession.ContainsKey(session.Name))))"
162+
HasContent="renderContent"
163+
WarmWhenHidden="@(renderContent && (completedSessions.Contains(session.Name) || (session.IsProcessing && (streamingBySession.ContainsKey(session.Name) || currentToolBySession.ContainsKey(session.Name) || activityBySession.ContainsKey(session.Name)))))"
161164
HiddenWarmIntervalMs="250"
162165
@key="session.Name">
166+
@if (renderContent)
167+
{
163168
<ExpandedSessionView Session="session"
164169
IsCompleted="@completedSessions.Contains(session.Name)"
165170
IsLoadingHistory="@(CopilotService.IsRemoteMode && session.MessageCount > 0 && session.History.Count == 0)"
@@ -201,6 +206,7 @@
201206
OnStopFiesta="StopFiestaForSession"
202207
OnStopReflection="() => StopReflectionForSession(session.Name)"
203208
OnReconnectRequested="() => CopilotService.RecoverSessionAsync(session.Name)" />
209+
}
204210
</KeepAliveSession>
205211
}
206212
}
@@ -553,6 +559,13 @@
553559
private string? mobileConnectError;
554560
private bool _needsScrollToBottom;
555561
private volatile bool _sessionSwitching; // true during ExpandSession to skip redundant JS interop
562+
563+
// Keep-alive slot cap: only render DOM for the N most recently accessed sessions.
564+
// All other sessions are evicted from the DOM to reduce node count.
565+
// Session state lives in CopilotService — no data loss on eviction.
566+
private const int MaxKeepAliveSlots = 5;
567+
private LinkedList<string> _mruSessions = new();
568+
private HashSet<string> _mruSet = new(); // O(1) lookup companion for _mruSessions
556569
private bool _loadMoreObserverInitialized;
557570
private bool isCompactGrid; // true = compact cards, false = spacious cards
558571
private int _gridColumns = 3; // number of cards per row (2-6)
@@ -657,6 +670,7 @@
657670
if (!string.IsNullOrEmpty(uiState.ExpandedSession))
658671
{
659672
expandedSession = uiState.ExpandedSession;
673+
TouchMru(uiState.ExpandedSession);
660674
_explicitlyCollapsed = false;
661675
_initialGridSet = true; // Prevent RefreshState from overriding
662676
// Don't set active session yet - will do after Initialize
@@ -1414,6 +1428,7 @@
14141428
if (selected != null && sessions.Any(s => s.Name == selected))
14151429
{
14161430
expandedSession = selected;
1431+
TouchMru(selected);
14171432
_lastActiveSession = selected;
14181433
_focusedInputId = $"input-{selected.Replace(" ", "-")}";
14191434
}
@@ -1431,12 +1446,14 @@
14311446
if (sessions.Count == 1 && expandedSession == null && !_explicitlyCollapsed)
14321447
{
14331448
expandedSession = sessions[0].Name;
1449+
TouchMru(sessions[0].Name);
14341450
CopilotService.SwitchSession(sessions[0].Name);
14351451
}
14361452
if (sessionSwitched && active != null && sessions.Any(s => s.Name == active))
14371453
{
14381454
_lastActiveSession = active;
14391455
expandedSession = active;
1456+
TouchMru(active);
14401457
_focusedInputId = $"input-{active!.Replace(" ", "-")}";
14411458
_needsScrollToBottom = true;
14421459
CopilotService.SaveUiState("/dashboard", activeSession: active, expandedSession: active, expandedGrid: !isCompactGrid);
@@ -1446,6 +1463,7 @@
14461463
{
14471464
_lastActiveSession = active;
14481465
expandedSession = active;
1466+
TouchMru(active);
14491467
_needsScrollToBottom = true;
14501468
}
14511469
else if (sessionSwitched && active == null)
@@ -2093,6 +2111,7 @@
20932111
{
20942112
CopilotService.SetActiveSession(created.Name);
20952113
expandedSession = created.Name;
2114+
TouchMru(created.Name);
20962115
_explicitlyCollapsed = false;
20972116
}
20982117
await InvokeAsync(SafeRefreshAsync);
@@ -2118,6 +2137,10 @@
21182137
var success = CopilotService.RenameSession(sessionName, arg);
21192138
if (success)
21202139
{
2140+
// Always clean up MRU for old name, not just when expanded
2141+
_mruSessions.Remove(sessionName);
2142+
_mruSet.Remove(sessionName);
2143+
TouchMru(arg);
21212144
if (expandedSession == sessionName)
21222145
expandedSession = arg;
21232146
sessions = CopilotService.GetAllSessions().ToList();
@@ -2964,11 +2987,55 @@
29642987
await JS.InvokeVoidAsync("clickElement", fileId);
29652988
}
29662989

2990+
private void TouchMru(string sessionName)
2991+
{
2992+
if (_mruSet.Contains(sessionName))
2993+
{
2994+
_mruSessions.Remove(sessionName);
2995+
}
2996+
_mruSessions.AddFirst(sessionName);
2997+
_mruSet.Add(sessionName);
2998+
// Evict oldest beyond cap — but never evict processing sessions
2999+
while (_mruSessions.Count > MaxKeepAliveSlots)
3000+
{
3001+
// Scan from tail for a non-processing candidate to evict
3002+
var node = _mruSessions.Last;
3003+
LinkedListNode<string>? evictTarget = null;
3004+
while (node != null)
3005+
{
3006+
var s = sessions.FirstOrDefault(s => s.Name == node.Value);
3007+
if (s?.IsProcessing != true)
3008+
{
3009+
evictTarget = node;
3010+
break;
3011+
}
3012+
node = node.Previous;
3013+
}
3014+
if (evictTarget == null)
3015+
break; // all entries are processing — allow temporary overflow
3016+
_mruSessions.Remove(evictTarget);
3017+
_mruSet.Remove(evictTarget.Value);
3018+
}
3019+
}
3020+
3021+
private bool ShouldRenderSlot(string sessionName)
3022+
{
3023+
// Always render: MRU sessions, the currently expanded session, the active session,
3024+
// and processing sessions. ActiveSessionName is checked as fallback because
3025+
// expandedSession may lag behind by one render cycle after sidebar clicks.
3026+
if (_mruSet.Contains(sessionName) || sessionName == expandedSession
3027+
|| sessionName == CopilotService.ActiveSessionName)
3028+
return true;
3029+
var session = sessions.FirstOrDefault(s => s.Name == sessionName);
3030+
return session?.IsProcessing == true;
3031+
}
3032+
29673033
private async Task ExpandSession(string sessionName)
29683034
{
29693035
// Fire-and-forget draft save — don't block the switch on JS interop
29703036
_ = SaveDraftsAndCursor();
29713037
expandedSession = sessionName;
3038+
TouchMru(sessionName);
29723039
_needsScrollToBottom = true;
29733040
_sessionSwitching = true; // tells RefreshState to skip redundant work
29743041
// Suppress Blazor renders for 300ms to let browser paint the CSS toggle uninterrupted
@@ -3061,7 +3128,15 @@
30613128
var newName = await JS.InvokeAsync<string>("getElementValue", "cardRenameInput");
30623129
if (!string.IsNullOrWhiteSpace(newName) && newName.Trim() != oldName)
30633130
{
3064-
CopilotService.RenameSession(oldName, newName.Trim());
3131+
var success = CopilotService.RenameSession(oldName, newName.Trim());
3132+
if (success)
3133+
{
3134+
_mruSessions.Remove(oldName);
3135+
_mruSet.Remove(oldName);
3136+
TouchMru(newName.Trim());
3137+
if (expandedSession == oldName)
3138+
expandedSession = newName.Trim();
3139+
}
30653140
}
30663141
}
30673142

@@ -3331,6 +3406,9 @@
33313406
private async Task CloseSession(string sessionName)
33323407
{
33333408
await CopilotService.CloseSessionAsync(sessionName);
3409+
// Clean up MRU to prevent stale entries occupying slots
3410+
_mruSessions.Remove(sessionName);
3411+
_mruSet.Remove(sessionName);
33343412
if (expandedSession == sessionName)
33353413
{
33363414
expandedSession = null;
@@ -3348,6 +3426,7 @@
33483426
CopilotService.SwitchSession(sessionName);
33493427
await SaveDraftsAndCursor();
33503428
expandedSession = sessionName;
3429+
TouchMru(sessionName);
33513430
_needsScrollToBottom = true;
33523431
_focusedInputId = $"input-{sessionName.Replace(" ", "-")}";
33533432
_cursorStart = 0;
@@ -3383,6 +3462,7 @@
33833462
{
33843463
await SaveDraftsAndCursor();
33853464
expandedSession = sessionName;
3465+
TouchMru(sessionName);
33863466
CopilotService.SwitchSession(sessionName);
33873467
var s = CopilotService.GetSession(sessionName);
33883468
if (s != null) s.LastReadMessageCount = s.History.Count;
@@ -3410,6 +3490,7 @@
34103490
if (idx < 0) idx = 0;
34113491
idx = reverse ? (idx - 1 + sessions.Count) % sessions.Count : (idx + 1) % sessions.Count;
34123492
expandedSession = sessions[idx].Name;
3493+
TouchMru(sessions[idx].Name);
34133494
CopilotService.SwitchSession(expandedSession);
34143495
var cs = CopilotService.GetSession(expandedSession);
34153496
if (cs != null) cs.LastReadMessageCount = cs.History.Count;
@@ -3460,6 +3541,7 @@
34603541
{
34613542
await SaveDraftsAndCursor();
34623543
expandedSession = session.Name;
3544+
TouchMru(session.Name);
34633545
_focusedInputId = $"input-{session.Name.Replace(" ", "-")}";
34643546
}
34653547
CopilotService.SwitchSession(session.Name);

PolyPilot/wwwroot/index.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,12 @@
657657
if (!name) return;
658658
var slotId = 'slot-' + name.replace(/ /g, '-');
659659
window.switchKeepAliveSlot(slotId, name);
660+
// Also tell Dashboard to expand this session — the sidebar's Blazor handler
661+
// only calls SwitchSession, which is async/throttled. JsExpandSession sets
662+
// expandedSession + TouchMru synchronously so the slot content renders immediately.
663+
if (window.__dashRef) {
664+
window.__dashRef.invokeMethodAsync('JsExpandSession', name);
665+
}
660666
}, true);
661667

662668
window.saveCardScrollPositions = function() {

0 commit comments

Comments
 (0)