diff --git a/CHANGELOG.md b/CHANGELOG.md index ac020547..11539b68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ ### Fixed +- Session sidebar scrolling remains usable in Firefox-family browsers, including Waterfox, while background session refreshes or streaming polls arrive. The sidebar now defers background list DOM replacement during active pointer/scroll interaction, preserves explicit user-triggered refreshes, avoids redundant virtualized-list rebuilds when the visible window is unchanged, and disables browser scroll anchoring on the session list. + - **PR #2171** by @franksong2702 — Session tail-window response (`/api/session?messages=1&resolve_model=0&msg_limit=30`) on long sessions is materially faster. Adds a cheap credential-marker prefilter before the full agent+fallback redaction pass — strings without known credential markers return immediately, while strings with likely markers still run the existing hard redaction. Skips the historical `session.tool_calls` list in the payload when returned messages already carry per-message tool metadata, avoiding sending the full historical list for every tail-window request. Same security and tool-card rendering behavior preserved. 173-line regression suite in `tests/test_session_tail_payload.py` + 81 LOC of new credential-prefilter tests in `tests/test_security_redaction.py`. - **PR #2182** by @LumenYoung — Compression banner no longer drifts away from the actual compaction boundary in long WebUI conversations. Fixes two related cases: (1) windowed transcript rendering — `renderMessages()` renders only a sliced `renderVisWithIdx` window, and when the compression anchor index wasn't found in the rendered window, the previous code passed the full visible index directly into the rendered-window array (usually out-of-window for long sessions), so the compression card fell back to `inner.appendChild(node)` and appeared near the newest messages instead of near the boundary; (2) persisted compaction reference messages — the `[CONTEXT COMPACTION — REFERENCE ONLY]` marker is now used as a stronger placement signal than anchor metadata when both are present. 60-line regression suite covering both cases. diff --git a/static/sessions.js b/static/sessions.js index 22d4799a..cd68cb53 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -93,6 +93,11 @@ let _sessionCompletionUnread = null; let _sessionObservedStreaming = null; const _sessionStreamingById = new Map(); const _sessionListSnapshotById = new Map(); +let _sessionListPointerActive = false; +let _sessionListLastScrollAt = 0; +let _pendingSessionListPayload = null; +let _pendingSessionListApplyTimer = 0; +const SESSION_LIST_INTERACTION_IDLE_MS = 700; function _formatSessionModelWithGateway(s){ if(!s||!s.model)return''; @@ -1767,8 +1772,66 @@ function _mergeOptimisticFirstTurnSessions(fetchedSessions){ return merged; } -async function renderSessionList(){ +function _isSessionListUserInteracting(){ + const now=Date.now(); + const list=$('sessionList'); + const pointerOverList=Boolean(list&&(list.matches(':hover')||list.matches(':focus-within'))); + return Boolean( + _sessionListPointerActive || + pointerOverList || + (_sessionListLastScrollAt && now-_sessionListLastScrollAt{ + _pendingSessionListApplyTimer=0; + if(!_pendingSessionListPayload) return; + if(_isSessionListUserInteracting()){ + _schedulePendingSessionListApply(); + return; + } + const payload=_pendingSessionListPayload; + _pendingSessionListPayload=null; + if(payload.gen!==_renderSessionListGen) return; + _applySessionListPayload(payload.sessData,payload.projData); + }, Math.max(120, SESSION_LIST_INTERACTION_IDLE_MS)); +} + +function _applySessionListPayload(sessData, projData){ + // Server's other_profile_count tells us how many sessions exist outside the + // active profile so the "Show N from other profiles" toggle can render + // without a second round-trip. Stashed on the module for renderSessionListFromCache. + _otherProfileCount = sessData.other_profile_count || 0; + _allSessions = _mergeOptimisticFirstTurnSessions(sessData.sessions||[]); + _clearLineageReportCache(); + _allProjects = projData.projects||[]; + // Capture server clock for clock-skew compensation (issue #1144). + // server_time is epoch seconds from the server's time.time(). + // _serverTimeDelta = client - server, so (Date.now() - _serverTimeDelta) + // gives an approximation of the current server time. + if (typeof sessData.server_time === 'number' && sessData.server_time > 0) { + _serverTimeDelta = Date.now() - (sessData.server_time * 1000); + } + if (typeof sessData.server_tz === 'string') { + _serverTz = sessData.server_tz; + } + _markPollingCompletionUnreadTransitions(_allSessions); + const isStreaming = _allSessions.some(s => Boolean(s && s.is_streaming)); + if (isStreaming) { + startStreamingPoll(); + } else { + stopStreamingPoll(); + } + ensureSessionTimeRefreshPoll(); + renderSessionListFromCache(); // no-ops if rename is in progress +} + +async function renderSessionList(opts={}){ + const deferWhileInteracting=Boolean(opts&&opts.deferWhileInteracting); const _gen = ++_renderSessionListGen; + if(!deferWhileInteracting) _pendingSessionListPayload=null; try{ if(!($('sessionSearch').value||'').trim()) _contentSearchResults = []; const allProfilesQS = _showAllProfiles ? '?all_profiles=1' : ''; @@ -1778,32 +1841,12 @@ async function renderSessionList(){ ]); // Discard stale response — a newer renderSessionList() call superseded us. if (_gen !== _renderSessionListGen) return; - // Server's other_profile_count tells us how many sessions exist outside the - // active profile so the "Show N from other profiles" toggle can render - // without a second round-trip. Stashed on the module for renderSessionListFromCache. - _otherProfileCount = sessData.other_profile_count || 0; - _allSessions = _mergeOptimisticFirstTurnSessions(sessData.sessions||[]); - _clearLineageReportCache(); - _allProjects = projData.projects||[]; - // Capture server clock for clock-skew compensation (issue #1144). - // server_time is epoch seconds from the server's time.time(). - // _serverTimeDelta = client - server, so (Date.now() - _serverTimeDelta) - // gives an approximation of the current server time. - if (typeof sessData.server_time === 'number' && sessData.server_time > 0) { - _serverTimeDelta = Date.now() - (sessData.server_time * 1000); + if(deferWhileInteracting&&_isSessionListUserInteracting()){ + _pendingSessionListPayload={gen:_gen,sessData,projData}; + _schedulePendingSessionListApply(); + return; } - if (typeof sessData.server_tz === 'string') { - _serverTz = sessData.server_tz; - } - _markPollingCompletionUnreadTransitions(_allSessions); - const isStreaming = _allSessions.some(s => Boolean(s && s.is_streaming)); - if (isStreaming) { - startStreamingPoll(); - } else { - stopStreamingPoll(); - } - ensureSessionTimeRefreshPoll(); - renderSessionListFromCache(); // no-ops if rename is in progress + _applySessionListPayload(sessData,projData); }catch(e){console.warn('renderSessionList',e);} } @@ -1821,7 +1864,7 @@ let _sessionTimeRefreshTimer = null; function startStreamingPoll(){ if(_streamingPollTimer) return; _streamingPollTimer = setInterval(() => { - void renderSessionList(); + void renderSessionList({deferWhileInteracting:true}); }, _streamingPollMs); } @@ -1841,7 +1884,7 @@ function ensureSessionTimeRefreshPoll(){ function startGatewayPollFallback(ms){ const intervalMs = Math.max(5000, Number(ms) || _gatewayFallbackPollMs); if(_gatewayPollTimer) clearInterval(_gatewayPollTimer); - _gatewayPollTimer = setInterval(() => { renderSessionList(); }, intervalMs); + _gatewayPollTimer = setInterval(() => { renderSessionList({deferWhileInteracting:true}); }, intervalMs); } function stopGatewayPollFallback(){ @@ -1864,7 +1907,7 @@ async function probeGatewaySSEStatus(){ } if(resp.status === 503 || data.watcher_running === false){ startGatewayPollFallback(data.fallback_poll_ms || _gatewayFallbackPollMs); - renderSessionList(); + renderSessionList({deferWhileInteracting:true}); if(!_gatewaySSEWarningShown && typeof showToast === 'function'){ showToast('Gateway sync unavailable — falling back to periodic refresh.', 5000); _gatewaySSEWarningShown = true; @@ -1875,7 +1918,7 @@ async function probeGatewaySSEStatus(){ // Start fallback polling as a safe default; it will self-cancel // when the SSE connection recovers and sessions_changed fires. startGatewayPollFallback(_gatewayFallbackPollMs); - renderSessionList(); + renderSessionList({deferWhileInteracting:true}); }finally{ _gatewayProbeInFlight = false; } @@ -1892,7 +1935,7 @@ function startGatewaySSE(){ if(data.sessions){ stopGatewayPollFallback(); _gatewaySSEWarningShown = false; - renderSessionList(); // re-fetch and re-render + renderSessionList({deferWhileInteracting:true}); // re-fetch and re-render // If the active session received new gateway messages, refresh the conversation view. // S.busy check prevents stomping on an in-progress WebUI response. // is_cli_session check ensures we only poll import_cli for CLI-originated sessions. @@ -2396,27 +2439,65 @@ function _sessionVirtualSpacer(height, where){ } function _scheduleSessionVirtualizedRender(){ + _sessionListLastScrollAt=Date.now(); if(_renamingSid||_sessionVirtualScrollRaf) return; + const list=_sessionVirtualScrollList; + const total=Number(list&&list.dataset&&list.dataset.sessionVirtualTotal||0); // Skip the re-render if the list is below the virtualization threshold — // there's no virtual window to recompute, and re-rendering would just // rebuild the whole DOM on every scroll tick. Without this guard, the // unconditional scroll listener (attached for any list) caused // user-facing scroll jumps on small lists. (#1669 follow-up) - const list=_sessionVirtualScrollList; - if(list){ - const total=Number(list.dataset.sessionVirtualTotal||0); - if(total>0&&total<=SESSION_VIRTUAL_THRESHOLD_ROWS) return; - } - _sessionVirtualScrollRaf=requestAnimationFrame(()=>{_sessionVirtualScrollRaf=0;renderSessionListFromCache();}); + if(total>0&&total<=SESSION_VIRTUAL_THRESHOLD_ROWS) return; + _sessionVirtualScrollRaf=requestAnimationFrame(()=>{ + _sessionVirtualScrollRaf=0; + const liveList=_sessionVirtualScrollList; + const liveTotal=Number(liveList&&liveList.dataset&&liveList.dataset.sessionVirtualTotal||0); + if(liveList&&liveTotal>SESSION_VIRTUAL_THRESHOLD_ROWS){ + const nextWindow=_sessionVirtualWindow({ + total:liveTotal, + scrollTop:liveList.scrollTop||0, + viewportHeight:liveList.clientHeight||520, + itemHeight:SESSION_VIRTUAL_ROW_HEIGHT, + buffer:SESSION_VIRTUAL_BUFFER_ROWS, + threshold:SESSION_VIRTUAL_THRESHOLD_ROWS, + activeIndex:-1, + }); + const currentStart=Number(liveList.dataset.sessionVirtualStart||0); + const currentEnd=Number(liveList.dataset.sessionVirtualEnd||0); + if(nextWindow.virtualized&&nextWindow.start===currentStart&&nextWindow.end===currentEnd) return; + } + renderSessionListFromCache(); + }); } function _ensureSessionVirtualScrollHandler(list){ - if(!list||_sessionVirtualScrollList===list) return; + if(!list) return; + if(_sessionVirtualScrollList===list) return; if(_sessionVirtualScrollList){ _sessionVirtualScrollList.removeEventListener('scroll', _scheduleSessionVirtualizedRender); + _sessionVirtualScrollList.removeEventListener('pointerdown', _markSessionListPointerDown); + _sessionVirtualScrollList.removeEventListener('pointerup', _markSessionListPointerUp); + _sessionVirtualScrollList.removeEventListener('pointercancel', _markSessionListPointerUp); + _sessionVirtualScrollList.removeEventListener('pointerleave', _markSessionListPointerUp); } _sessionVirtualScrollList=list; list.addEventListener('scroll', _scheduleSessionVirtualizedRender, {passive:true}); + list.addEventListener('pointerdown', _markSessionListPointerDown, {passive:true}); + list.addEventListener('pointerup', _markSessionListPointerUp, {passive:true}); + list.addEventListener('pointercancel', _markSessionListPointerUp, {passive:true}); + list.addEventListener('pointerleave', _markSessionListPointerUp, {passive:true}); +} + +function _markSessionListPointerDown(){ + _sessionListPointerActive=true; + _sessionListLastScrollAt=Date.now(); +} + +function _markSessionListPointerUp(){ + _sessionListPointerActive=false; + _sessionListLastScrollAt=Date.now(); + if(_pendingSessionListPayload) _schedulePendingSessionListApply(); } function renderSessionListFromCache(){ diff --git a/static/style.css b/static/style.css index 7b463d4c..6b6f55ed 100644 --- a/static/style.css +++ b/static/style.css @@ -321,7 +321,7 @@ .sidebar-section{padding:14px 14px 8px;} .new-chat-btn{width:100%;padding:9px 12px;border-radius:9px;background:var(--accent-bg);border:1px solid var(--accent-bg-strong);color:var(--accent-text);font-size:13px;cursor:pointer;display:flex;align-items:center;gap:8px;transition:all .15s;margin-bottom:8px;font-weight:500;} .new-chat-btn:hover{background:var(--accent-bg-strong);border-color:var(--accent);} - .session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;overscroll-behavior-y:contain;touch-action:pan-y;} + .session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;overscroll-behavior-y:contain;touch-action:pan-y;overflow-anchor:none;} .sidebar-search{position:relative;padding:8px 12px;flex-shrink:0;} .sidebar-search input{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 10px 7px 32px;font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s,background .15s;box-sizing:border-box;} .sidebar-search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg);} diff --git a/tests/test_firefox_sidebar_scroll_stability.py b/tests/test_firefox_sidebar_scroll_stability.py new file mode 100644 index 00000000..5c9e00c6 --- /dev/null +++ b/tests/test_firefox_sidebar_scroll_stability.py @@ -0,0 +1,84 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") +STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8") + + +def _block(start_marker: str, end_marker: str) -> str: + start = SESSIONS_JS.find(start_marker) + assert start != -1, f"{start_marker} not found" + end = SESSIONS_JS.find(end_marker, start) + assert end != -1, f"{end_marker} not found after {start_marker}" + return SESSIONS_JS[start:end] + + +def test_session_list_disables_browser_scroll_anchoring(): + session_list_rule_start = STYLE_CSS.find(".session-list{") + assert session_list_rule_start != -1 + session_list_rule = STYLE_CSS[session_list_rule_start:STYLE_CSS.find("}", session_list_rule_start)] + assert "overflow-anchor:none" in session_list_rule, ( + "Firefox/Waterfox scroll anchoring can fight virtualized sidebar DOM " + "replacement and rubber-band the session list." + ) + + +def test_polling_payloads_are_deferred_while_user_scrolls_sidebar(): + render_block = _block("async function renderSessionList", "// ── Gateway session SSE") + apply_block = _block("function _applySessionListPayload", "async function renderSessionList") + + assert "function _isSessionListUserInteracting()" in SESSIONS_JS + assert "async function renderSessionList(opts={})" in render_block + assert "const deferWhileInteracting=Boolean(opts&&opts.deferWhileInteracting);" in render_block + assert "if(deferWhileInteracting&&_isSessionListUserInteracting())" in render_block + assert "_pendingSessionListPayload={gen:_gen,sessData,projData};" in render_block + assert "_schedulePendingSessionListApply();" in render_block + assert "_applySessionListPayload(sessData,projData);" in render_block + assert "_markPollingCompletionUnreadTransitions(_allSessions);" in apply_block, ( + "deferring sidebar refreshes must preserve background-completion unread semantics" + ) + + +def test_deferred_payloads_keep_generation_stale_response_guard(): + schedule_apply_block = _block("function _schedulePendingSessionListApply", "function _applySessionListPayload") + render_block = _block("async function renderSessionList", "// ── Gateway session SSE") + + assert "if(!deferWhileInteracting) _pendingSessionListPayload=null;" in render_block, ( + "explicit/user-initiated refreshes must clear older deferred background payloads" + ) + assert "payload.gen!==_renderSessionListGen" in schedule_apply_block, ( + "a deferred polling response must not apply after a newer renderSessionList generation starts" + ) + + +def test_only_background_refreshes_defer_while_sidebar_is_interacting(): + streaming_poll_block = _block("function startStreamingPoll", "function stopStreamingPoll") + gateway_poll_block = _block("function startGatewayPollFallback", "function stopGatewayPollFallback") + gateway_sse_block = _block("function startGatewaySSE", "function stopGatewaySSE") + + assert "renderSessionList({deferWhileInteracting:true})" in streaming_poll_block + assert "renderSessionList({deferWhileInteracting:true})" in gateway_poll_block + assert "renderSessionList({deferWhileInteracting:true}); // re-fetch and re-render" in gateway_sse_block + assert "pfToggle.onclick=()=>{_showAllProfiles=true;renderSessionList();};" in SESSIONS_JS + assert "pfToggle.onclick=()=>{_showAllProfiles=false;renderSessionList();};" in SESSIONS_JS + +def test_session_list_pointer_hover_and_scroll_activity_are_tracked(): + interaction_block = _block("function _isSessionListUserInteracting()", "function _schedulePendingSessionListApply") + schedule_block = _block("function _scheduleSessionVirtualizedRender()", "function _ensureSessionVirtualScrollHandler") + ensure_block = _block("function _ensureSessionVirtualScrollHandler", "function renderSessionListFromCache") + + assert "list.matches(':hover')" in interaction_block + assert "list.matches(':focus-within')" in interaction_block + assert "_sessionListLastScrollAt=Date.now();" in schedule_block + for event_name in ["pointerdown", "pointerup", "pointercancel", "pointerleave"]: + assert event_name in ensure_block + assert "_sessionListPointerActive=true;" in ensure_block + assert "_sessionListPointerActive=false;" in ensure_block + + +def test_virtual_scroll_skips_dom_rebuild_when_window_is_unchanged(): + schedule_block = _block("function _scheduleSessionVirtualizedRender()", "function _ensureSessionVirtualScrollHandler") + assert "nextWindow.start===currentStart" in schedule_block + assert "nextWindow.end===currentEnd" in schedule_block + assert "return;" in schedule_block + assert "renderSessionListFromCache();" in schedule_block diff --git a/tests/test_issue500_session_list_virtualization.py b/tests/test_issue500_session_list_virtualization.py index 3ff6db33..dc6270f8 100644 --- a/tests/test_issue500_session_list_virtualization.py +++ b/tests/test_issue500_session_list_virtualization.py @@ -111,7 +111,9 @@ def test_session_list_render_path_uses_virtual_spacers_and_scroll_rerender(): assert "_sessionVirtualSpacer" in render_body assert "spacer.dataset.virtualSpacer=where||'gap'" in js assert "list.addEventListener('scroll', _scheduleSessionVirtualizedRender" in js - assert "requestAnimationFrame(()=>{_sessionVirtualScrollRaf=0;renderSessionListFromCache();})" in js + assert "requestAnimationFrame(()=>{" in js + assert "_sessionVirtualScrollRaf=0;" in js + assert "renderSessionListFromCache();" in js assert "const listScrollTopBeforeRender=list.scrollTop||0" in render_body assert "scrollTop:listScrollTopBeforeRender" in render_body assert "list.scrollTop=listScrollTopBeforeRender" in render_body diff --git a/tests/test_issue856_background_completion_unread.py b/tests/test_issue856_background_completion_unread.py index 324a535e..62ee9f64 100644 --- a/tests/test_issue856_background_completion_unread.py +++ b/tests/test_issue856_background_completion_unread.py @@ -119,10 +119,14 @@ def test_polling_transition_marks_completion_unread_without_sse_done(): "_isSessionEffectivelyStreaming", "_markPollingCompletionUnreadTransitions", ) - render_idx = SESSIONS_JS.find("async function renderSessionList()") + render_idx = SESSIONS_JS.find("async function renderSessionList") assert render_idx != -1, "renderSessionList not found" render_block = SESSIONS_JS[render_idx:SESSIONS_JS.find("// ── Gateway session SSE", render_idx)] + apply_idx = SESSIONS_JS.find("function _applySessionListPayload(") + assert apply_idx != -1, "_applySessionListPayload not found" + apply_block = SESSIONS_JS[apply_idx:render_idx] + assert "const _sessionStreamingById = new Map();" in SESSIONS_JS assert "const wasStreaming = _sessionStreamingById.get(sid);" in transition_block assert "const isStreaming = _isSessionEffectivelyStreaming(s);" in transition_block @@ -132,7 +136,8 @@ def test_polling_transition_marks_completion_unread_without_sse_done(): ) assert "_markSessionCompletionUnread(sid, s.message_count);" in transition_block assert "_sessionStreamingById.set(sid, isStreaming);" in transition_block - assert "_markPollingCompletionUnreadTransitions(_allSessions);" in render_block + assert "_applySessionListPayload(sessData,projData);" in render_block + assert "_markPollingCompletionUnreadTransitions(_allSessions);" in apply_block def test_polling_transition_does_not_mark_historical_first_render(): diff --git a/tests/test_sidebar_first_turn_visibility.py b/tests/test_sidebar_first_turn_visibility.py index f1256d16..77ba7860 100644 --- a/tests/test_sidebar_first_turn_visibility.py +++ b/tests/test_sidebar_first_turn_visibility.py @@ -106,11 +106,11 @@ class TestSidebarFirstTurnVisibility: "the server has saved pending state, and replacing _allSessions would hide the " "new in-flight chat until the stream finishes." ) - render_start = src.index("async function renderSessionList") - render_end = src.index("// ── Gateway session SSE", render_start) - render_body = src[render_start:render_end] - assign_idx = render_body.index("_allSessions =") - assert "_mergeOptimisticFirstTurnSessions" in render_body[:assign_idx + 160], ( + apply_start = src.index("function _applySessionListPayload") + apply_end = src.index("async function renderSessionList", apply_start) + apply_body = src[apply_start:apply_end] + assign_idx = apply_body.index("_allSessions =") + assert "_mergeOptimisticFirstTurnSessions" in apply_body[:assign_idx + 160], ( "The fetched session list should be merged with optimistic rows at the assignment " "site, before completion transitions or renderSessionListFromCache() run." ) diff --git a/tests/test_streaming_sidebar_scroll.py b/tests/test_streaming_sidebar_scroll.py index f4dcecec..83a9143b 100644 --- a/tests/test_streaming_sidebar_scroll.py +++ b/tests/test_streaming_sidebar_scroll.py @@ -48,4 +48,4 @@ def test_scroll_if_pinned_skips_during_recent_non_message_scroll(): def test_session_list_has_its_own_scroll_boundary(): """The session list is its own scroll surface, not chained to the chat/body scroller.""" - assert ".session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;overscroll-behavior-y:contain;touch-action:pan-y;}" in STYLE_CSS + assert ".session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;overscroll-behavior-y:contain;touch-action:pan-y;overflow-anchor:none;}" in STYLE_CSS