mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
fix: stabilize Firefox session sidebar scrolling
This commit is contained in:
@@ -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.
|
||||
|
||||
+119
-38
@@ -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<SESSION_LIST_INTERACTION_IDLE_MS)
|
||||
);
|
||||
}
|
||||
|
||||
function _schedulePendingSessionListApply(){
|
||||
if(_pendingSessionListApplyTimer) clearTimeout(_pendingSessionListApplyTimer);
|
||||
_pendingSessionListApplyTimer=setTimeout(()=>{
|
||||
_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(){
|
||||
|
||||
+1
-1
@@ -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);}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user