|
152 | 152 | } |
153 | 153 | </div> |
154 | 154 | } |
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 *@ |
156 | 157 | @foreach (var session in sessions) |
157 | 158 | { |
| 159 | + var renderContent = ShouldRenderSlot(session.Name); |
158 | 160 | <KeepAliveSession Id="@($"slot-{session.Name.Replace(" ", "-")}")" |
159 | 161 | 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)))))" |
161 | 164 | HiddenWarmIntervalMs="250" |
162 | 165 | @key="session.Name"> |
| 166 | + @if (renderContent) |
| 167 | + { |
163 | 168 | <ExpandedSessionView Session="session" |
164 | 169 | IsCompleted="@completedSessions.Contains(session.Name)" |
165 | 170 | IsLoadingHistory="@(CopilotService.IsRemoteMode && session.MessageCount > 0 && session.History.Count == 0)" |
|
201 | 206 | OnStopFiesta="StopFiestaForSession" |
202 | 207 | OnStopReflection="() => StopReflectionForSession(session.Name)" |
203 | 208 | OnReconnectRequested="() => CopilotService.RecoverSessionAsync(session.Name)" /> |
| 209 | + } |
204 | 210 | </KeepAliveSession> |
205 | 211 | } |
206 | 212 | } |
|
553 | 559 | private string? mobileConnectError; |
554 | 560 | private bool _needsScrollToBottom; |
555 | 561 | 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 |
556 | 569 | private bool _loadMoreObserverInitialized; |
557 | 570 | private bool isCompactGrid; // true = compact cards, false = spacious cards |
558 | 571 | private int _gridColumns = 3; // number of cards per row (2-6) |
|
657 | 670 | if (!string.IsNullOrEmpty(uiState.ExpandedSession)) |
658 | 671 | { |
659 | 672 | expandedSession = uiState.ExpandedSession; |
| 673 | + TouchMru(uiState.ExpandedSession); |
660 | 674 | _explicitlyCollapsed = false; |
661 | 675 | _initialGridSet = true; // Prevent RefreshState from overriding |
662 | 676 | // Don't set active session yet - will do after Initialize |
|
1414 | 1428 | if (selected != null && sessions.Any(s => s.Name == selected)) |
1415 | 1429 | { |
1416 | 1430 | expandedSession = selected; |
| 1431 | + TouchMru(selected); |
1417 | 1432 | _lastActiveSession = selected; |
1418 | 1433 | _focusedInputId = $"input-{selected.Replace(" ", "-")}"; |
1419 | 1434 | } |
|
1431 | 1446 | if (sessions.Count == 1 && expandedSession == null && !_explicitlyCollapsed) |
1432 | 1447 | { |
1433 | 1448 | expandedSession = sessions[0].Name; |
| 1449 | + TouchMru(sessions[0].Name); |
1434 | 1450 | CopilotService.SwitchSession(sessions[0].Name); |
1435 | 1451 | } |
1436 | 1452 | if (sessionSwitched && active != null && sessions.Any(s => s.Name == active)) |
1437 | 1453 | { |
1438 | 1454 | _lastActiveSession = active; |
1439 | 1455 | expandedSession = active; |
| 1456 | + TouchMru(active); |
1440 | 1457 | _focusedInputId = $"input-{active!.Replace(" ", "-")}"; |
1441 | 1458 | _needsScrollToBottom = true; |
1442 | 1459 | CopilotService.SaveUiState("/dashboard", activeSession: active, expandedSession: active, expandedGrid: !isCompactGrid); |
|
1446 | 1463 | { |
1447 | 1464 | _lastActiveSession = active; |
1448 | 1465 | expandedSession = active; |
| 1466 | + TouchMru(active); |
1449 | 1467 | _needsScrollToBottom = true; |
1450 | 1468 | } |
1451 | 1469 | else if (sessionSwitched && active == null) |
|
2093 | 2111 | { |
2094 | 2112 | CopilotService.SetActiveSession(created.Name); |
2095 | 2113 | expandedSession = created.Name; |
| 2114 | + TouchMru(created.Name); |
2096 | 2115 | _explicitlyCollapsed = false; |
2097 | 2116 | } |
2098 | 2117 | await InvokeAsync(SafeRefreshAsync); |
|
2118 | 2137 | var success = CopilotService.RenameSession(sessionName, arg); |
2119 | 2138 | if (success) |
2120 | 2139 | { |
| 2140 | + // Always clean up MRU for old name, not just when expanded |
| 2141 | + _mruSessions.Remove(sessionName); |
| 2142 | + _mruSet.Remove(sessionName); |
| 2143 | + TouchMru(arg); |
2121 | 2144 | if (expandedSession == sessionName) |
2122 | 2145 | expandedSession = arg; |
2123 | 2146 | sessions = CopilotService.GetAllSessions().ToList(); |
|
2964 | 2987 | await JS.InvokeVoidAsync("clickElement", fileId); |
2965 | 2988 | } |
2966 | 2989 |
|
| 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 | + |
2967 | 3033 | private async Task ExpandSession(string sessionName) |
2968 | 3034 | { |
2969 | 3035 | // Fire-and-forget draft save — don't block the switch on JS interop |
2970 | 3036 | _ = SaveDraftsAndCursor(); |
2971 | 3037 | expandedSession = sessionName; |
| 3038 | + TouchMru(sessionName); |
2972 | 3039 | _needsScrollToBottom = true; |
2973 | 3040 | _sessionSwitching = true; // tells RefreshState to skip redundant work |
2974 | 3041 | // Suppress Blazor renders for 300ms to let browser paint the CSS toggle uninterrupted |
|
3061 | 3128 | var newName = await JS.InvokeAsync<string>("getElementValue", "cardRenameInput"); |
3062 | 3129 | if (!string.IsNullOrWhiteSpace(newName) && newName.Trim() != oldName) |
3063 | 3130 | { |
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 | + } |
3065 | 3140 | } |
3066 | 3141 | } |
3067 | 3142 |
|
|
3331 | 3406 | private async Task CloseSession(string sessionName) |
3332 | 3407 | { |
3333 | 3408 | await CopilotService.CloseSessionAsync(sessionName); |
| 3409 | + // Clean up MRU to prevent stale entries occupying slots |
| 3410 | + _mruSessions.Remove(sessionName); |
| 3411 | + _mruSet.Remove(sessionName); |
3334 | 3412 | if (expandedSession == sessionName) |
3335 | 3413 | { |
3336 | 3414 | expandedSession = null; |
|
3348 | 3426 | CopilotService.SwitchSession(sessionName); |
3349 | 3427 | await SaveDraftsAndCursor(); |
3350 | 3428 | expandedSession = sessionName; |
| 3429 | + TouchMru(sessionName); |
3351 | 3430 | _needsScrollToBottom = true; |
3352 | 3431 | _focusedInputId = $"input-{sessionName.Replace(" ", "-")}"; |
3353 | 3432 | _cursorStart = 0; |
|
3383 | 3462 | { |
3384 | 3463 | await SaveDraftsAndCursor(); |
3385 | 3464 | expandedSession = sessionName; |
| 3465 | + TouchMru(sessionName); |
3386 | 3466 | CopilotService.SwitchSession(sessionName); |
3387 | 3467 | var s = CopilotService.GetSession(sessionName); |
3388 | 3468 | if (s != null) s.LastReadMessageCount = s.History.Count; |
|
3410 | 3490 | if (idx < 0) idx = 0; |
3411 | 3491 | idx = reverse ? (idx - 1 + sessions.Count) % sessions.Count : (idx + 1) % sessions.Count; |
3412 | 3492 | expandedSession = sessions[idx].Name; |
| 3493 | + TouchMru(sessions[idx].Name); |
3413 | 3494 | CopilotService.SwitchSession(expandedSession); |
3414 | 3495 | var cs = CopilotService.GetSession(expandedSession); |
3415 | 3496 | if (cs != null) cs.LastReadMessageCount = cs.History.Count; |
|
3460 | 3541 | { |
3461 | 3542 | await SaveDraftsAndCursor(); |
3462 | 3543 | expandedSession = session.Name; |
| 3544 | + TouchMru(session.Name); |
3463 | 3545 | _focusedInputId = $"input-{session.Name.Replace(" ", "-")}"; |
3464 | 3546 | } |
3465 | 3547 | CopilotService.SwitchSession(session.Name); |
|
0 commit comments