mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-20 14:40:35 +00:00
* fix(streaming): show new-message cue when preserving scroll position (#3545) # Conflicts: # static/ui.js # tests/test_issue1690_scroll_completion.py # tests/test_tars_scroll_reset_regressions.py * i18n: add missing Polish (pl) translation for session_new_message keys The PR added session_new_message / session_new_message_label to 12 of 13 locales; Polish was missing both, which fails the per-locale parity test. Co-authored-by: rodboev <rodboev@users.noreply.github.com> * fix(streaming): keep forced follow path for pinned users in preserve-scroll branch Codex CORE catch: the PR's preserve-scroll branch used 'if(_scrollPinned) scrollIfPinned()' which skips the synchronous bottom write unless distance>500 and can have its delayed settles cancelled by the DOM-rebuild scroll event — leaving a pinned reader a few lines above the settled final response. Restore master's _followMessagesAfterDomReplace() forced-scrollToBottom() path for pinned/near-bottom users; only genuinely scrolled-up (unpinned, not near bottom) users restore their viewport and get the new-message cue. Updated the 3 structure-pinning tests to assert the corrected (safer) shape while preserving their behavioral intent. Co-authored-by: rodboev <rodboev@users.noreply.github.com> * Release v0.51.334 — Release KX (new-message cue when scrolled up, #3631) New-message cue on the jump-to-bottom button when the user has scrolled up during a live turn (#3545/#3631, @rodboev). Deep-reviewed (Opus+Codex); maintainer fixes during re-gate: (1) restored master's forced follow path for pinned/near-bottom users (Codex CORE: scrollIfPinned could leave a pinned reader short of the settled response) + updated 3 structure-pinning tests to the corrected shape; (2) added missing Polish (pl) i18n keys (PR had 12/13). Full suite 8308, ESLint/scope-undef CLEAN, Opus SHIP-safe, Codex SAFE-TO-SHIP. Co-authored-by: rodboev <rodboev@users.noreply.github.com> --------- Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local> Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
@@ -3,6 +3,11 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.334] — 2026-06-08 — Release KX (new-message cue when scrolled up)
|
||||
|
||||
### Added
|
||||
- **A "New message" cue on the jump-to-bottom button when you've scrolled up.** If you scroll up to read while a turn is still arriving, a new message no longer silently lands off-screen nor yanks you to the bottom — the jump-to-bottom button shows a "New message" cue you can click to catch up. Pinned/at-bottom readers still auto-follow to the latest response as before. (#3545, #3631, @rodboev)
|
||||
|
||||
## [v0.51.333] — 2026-06-08 — Release KW (collapse old interim progress notes)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -165,6 +165,8 @@ const LOCALES = {
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
session_new_message: 'New message',
|
||||
session_new_message_label: 'New message available, jump to end',
|
||||
jump_to_question: 'to question',
|
||||
jump_to_question_label: 'Jump to the question for this response',
|
||||
queued_label: 'Sends after response',
|
||||
@@ -1548,6 +1550,8 @@ const LOCALES = {
|
||||
session_jump_start_label: "Vai all'inizio della sessione",
|
||||
session_jump_end: 'Fine',
|
||||
session_jump_end_label: 'Vai alla fine della sessione',
|
||||
session_new_message: 'Nuovo messaggio',
|
||||
session_new_message_label: 'Nuovo messaggio disponibile, vai alla fine',
|
||||
jump_to_question: 'alla domanda',
|
||||
jump_to_question_label: 'Vai alla domanda di questa risposta',
|
||||
queued_label: 'Inviato dopo la risposta',
|
||||
@@ -2923,6 +2927,8 @@ const LOCALES = {
|
||||
session_jump_start_label: 'セッションの先頭へ移動',
|
||||
session_jump_end: '末尾',
|
||||
session_jump_end_label: 'セッションの末尾へ移動',
|
||||
session_new_message: '新着メッセージ',
|
||||
session_new_message_label: '新着メッセージがあります。末尾へ移動',
|
||||
jump_to_question: '質問へ',
|
||||
jump_to_question_label: 'この回答の質問へ移動',
|
||||
queued_label: '応答後に送信',
|
||||
@@ -4275,6 +4281,8 @@ const LOCALES = {
|
||||
session_jump_start_label: 'Перейти к началу сессии',
|
||||
session_jump_end: 'Конец',
|
||||
session_jump_end_label: 'Перейти к концу сессии',
|
||||
session_new_message: 'Новое сообщение',
|
||||
session_new_message_label: 'Новое сообщение доступно, перейти к концу',
|
||||
jump_to_question: 'к вопросу',
|
||||
jump_to_question_label: 'Перейти к вопросу для этого ответа',
|
||||
queued_label: 'Отправить после ответа',
|
||||
@@ -5595,6 +5603,8 @@ const LOCALES = {
|
||||
session_jump_start_label: 'Saltar al inicio de la sesión',
|
||||
session_jump_end: 'Fin',
|
||||
session_jump_end_label: 'Saltar al final de la sesión',
|
||||
session_new_message: 'Mensaje nuevo',
|
||||
session_new_message_label: 'Mensaje nuevo disponible, saltar al final',
|
||||
jump_to_question: 'a la pregunta',
|
||||
jump_to_question_label: 'Saltar a la pregunta de esta respuesta',
|
||||
queued_label: 'Enviar después de la respuesta',
|
||||
@@ -6906,6 +6916,8 @@ const LOCALES = {
|
||||
session_jump_start_label: 'Zum Anfang der Sitzung springen',
|
||||
session_jump_end: 'Ende',
|
||||
session_jump_end_label: 'Zum Ende der Sitzung springen',
|
||||
session_new_message: 'Neue Nachricht',
|
||||
session_new_message_label: 'Neue Nachricht verfügbar, zum Ende springen',
|
||||
jump_to_question: 'zur Frage',
|
||||
jump_to_question_label: 'Zur Frage dieser Antwort springen',
|
||||
queued_label: 'Wird nach Antwort gesendet',
|
||||
@@ -8221,6 +8233,8 @@ const LOCALES = {
|
||||
session_jump_start_label: '跳转到会话开头',
|
||||
session_jump_end: '结尾',
|
||||
session_jump_end_label: '跳转到会话结尾',
|
||||
session_new_message: '新消息',
|
||||
session_new_message_label: '有新消息,跳转到结尾',
|
||||
jump_to_question: '回到问题',
|
||||
jump_to_question_label: '跳转到这条回答对应的问题',
|
||||
queued_label: '响应后发送',
|
||||
@@ -9555,6 +9569,8 @@ const LOCALES = {
|
||||
session_jump_start_label: '跳至對話開頭',
|
||||
session_jump_end: '結尾',
|
||||
session_jump_end_label: '跳至對話結尾',
|
||||
session_new_message: '新訊息',
|
||||
session_new_message_label: '有新訊息,跳至結尾',
|
||||
jump_to_question: '回到問題',
|
||||
jump_to_question_label: '跳至這則回答對應的問題',
|
||||
queued_label: '回應後傳送',
|
||||
@@ -10840,6 +10856,8 @@ const LOCALES = {
|
||||
session_jump_start_label: 'Ir para o início da sessão',
|
||||
session_jump_end: 'Fim',
|
||||
session_jump_end_label: 'Ir para o fim da sessão',
|
||||
session_new_message: 'Nova mensagem',
|
||||
session_new_message_label: 'Nova mensagem disponível, ir para o fim',
|
||||
jump_to_question: 'para a pergunta',
|
||||
jump_to_question_label: 'Ir para a pergunta desta resposta',
|
||||
queued_label: 'Envia após a resposta',
|
||||
@@ -12097,6 +12115,8 @@ const LOCALES = {
|
||||
session_jump_start_label: '세션 시작으로 이동',
|
||||
session_jump_end: '끝',
|
||||
session_jump_end_label: '세션 끝으로 이동',
|
||||
session_new_message: '새 메시지',
|
||||
session_new_message_label: '새 메시지가 있습니다, 끝으로 이동',
|
||||
jump_to_question: '질문으로',
|
||||
jump_to_question_label: '이 응답의 질문으로 이동',
|
||||
queued_label: 'Sends after response',
|
||||
@@ -13478,6 +13498,8 @@ const LOCALES = {
|
||||
session_jump_start_label: 'Aller au début de la session',
|
||||
session_jump_end: 'Fin',
|
||||
session_jump_end_label: 'Aller à la fin de la session',
|
||||
session_new_message: 'Nouveau message',
|
||||
session_new_message_label: 'Nouveau message disponible, aller à la fin',
|
||||
jump_to_question: 'à la question',
|
||||
jump_to_question_label: 'Aller à la question de cette réponse',
|
||||
queued_label: 'Envoie après réponse',
|
||||
@@ -14786,6 +14808,8 @@ const LOCALES = {
|
||||
session_jump_start_label: 'Oturumun başlangıcına atla',
|
||||
session_jump_end: 'Son',
|
||||
session_jump_end_label: 'Oturumun sonuna atla',
|
||||
session_new_message: 'Yeni mesaj',
|
||||
session_new_message_label: 'Yeni mesaj mevcut, sona atla',
|
||||
jump_to_question: 'sorgulamak',
|
||||
jump_to_question_label: 'Bu yanıt için soruya geçin',
|
||||
queued_label: 'Yanıttan sonra gönderilir',
|
||||
@@ -16162,6 +16186,8 @@ const LOCALES = {
|
||||
session_jump_start_label: 'Przejdź do początku sesji',
|
||||
session_jump_end: 'Koniec',
|
||||
session_jump_end_label: 'Przejdź do końca sesji',
|
||||
session_new_message: 'Nowa wiadomość',
|
||||
session_new_message_label: 'Dostępna nowa wiadomość, przejdź do końca',
|
||||
jump_to_question: 'do pytania',
|
||||
jump_to_question_label: 'Przejdź do pytania dotyczącego tej odpowiedzi',
|
||||
queued_label: 'Zostanie wysłane po odpowiedzi',
|
||||
|
||||
@@ -1589,6 +1589,8 @@
|
||||
/* Overlay scroll controls so they do not affect the transcript's native scroll geometry. */
|
||||
.scroll-to-bottom-btn{position:absolute;right:20px;bottom:16px;width:32px;height:32px;border-radius:50%;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:10;transition:color .12s,border-color .12s,background .12s,transform .12s;}
|
||||
.scroll-to-bottom-btn:hover{color:var(--text);border-color:var(--border);background:var(--hover-bg);}
|
||||
.scroll-to-bottom-btn.scroll-to-bottom-btn--new-message{width:auto;min-width:32px;max-width:min(220px,calc(100% - 40px));border-radius:999px;font-size:12px;font-weight:600;gap:5px;padding:0 11px;color:var(--accent-text);border-color:var(--accent-bg-strong);background:var(--accent-bg);}
|
||||
.scroll-to-bottom-btn.scroll-to-bottom-btn--new-message .session-jump-btn__text{display:inline;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.session-jump-btn__text{display:none;}
|
||||
.session-jump-btn{position:absolute;right:20px;height:32px;border-radius:999px;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:12px;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:5px;padding:0 11px;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:11;transition:color .12s,border-color .12s,background .12s,opacity .12s,transform .12s;}
|
||||
.session-jump-btn:hover{color:var(--text);border-color:var(--border);background:var(--hover-bg);transform:translateY(-1px);}
|
||||
|
||||
+57
-4
@@ -2330,6 +2330,7 @@ let _messageUserUnpinned=false;
|
||||
let _bottomSettleToken=0;
|
||||
const NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS=350;
|
||||
let _touchStartY=null;
|
||||
let _newMessageCueVisible=false;
|
||||
function _cancelBottomSettle(){ _bottomSettleToken++; }
|
||||
function _recordNonMessageScrollIntent(e){
|
||||
const el=document.getElementById('messages');
|
||||
@@ -2360,6 +2361,46 @@ function _recordNonMessageScrollIntent(e){
|
||||
function _recentNonMessageScrollIntent(){
|
||||
return performance.now()-_lastNonMessageScrollIntentMs<NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS;
|
||||
}
|
||||
function _setScrollToBottomCueText(btn, textKey, labelKey){
|
||||
if(!btn) return;
|
||||
const label=btn.querySelector('.session-jump-btn__text');
|
||||
if(label){
|
||||
label.setAttribute('data-i18n',textKey);
|
||||
label.textContent=(typeof t==='function')?t(textKey):label.textContent;
|
||||
}
|
||||
btn.setAttribute('data-i18n-aria-label',labelKey);
|
||||
btn.setAttribute('data-i18n-title',labelKey);
|
||||
const accessible=(typeof t==='function')?t(labelKey):btn.getAttribute('aria-label')||'';
|
||||
if(accessible){
|
||||
btn.setAttribute('aria-label',accessible);
|
||||
btn.setAttribute('title',accessible);
|
||||
}
|
||||
}
|
||||
function _syncScrollToBottomCue(show, opts){
|
||||
const btn=$('scrollToBottomBtn');
|
||||
if(!btn) return;
|
||||
const newMessage=!!(opts&&opts.newMessage);
|
||||
btn.classList.toggle('scroll-to-bottom-btn--new-message',newMessage);
|
||||
if(newMessage) _setScrollToBottomCueText(btn,'session_new_message','session_new_message_label');
|
||||
else _setScrollToBottomCueText(btn,'session_jump_end','session_jump_end_label');
|
||||
btn.style.display=show?'flex':'none';
|
||||
}
|
||||
function _showNewMessageScrollCue(){
|
||||
_newMessageCueVisible=true;
|
||||
_syncScrollToBottomCue(true,{newMessage:true});
|
||||
}
|
||||
function _clearNewMessageScrollCue(){
|
||||
_newMessageCueVisible=false;
|
||||
_syncScrollToBottomCue(false,{newMessage:false});
|
||||
}
|
||||
function _maybeShowNewMessageScrollCue(scrollSnapshot){
|
||||
const el=document.getElementById('messages');
|
||||
if(!el||!scrollSnapshot) return;
|
||||
const previousHeight=Number(scrollSnapshot.scrollHeight)||0;
|
||||
const distance=el.scrollHeight-el.scrollTop-el.clientHeight;
|
||||
if(el.scrollHeight>previousHeight+24 && distance>80) _showNewMessageScrollCue();
|
||||
else _syncScrollToBottomCue(distance>80,{newMessage:_newMessageCueVisible});
|
||||
}
|
||||
if(typeof document!=='undefined'){
|
||||
document.addEventListener('wheel',_recordNonMessageScrollIntent,{capture:true,passive:true});
|
||||
document.addEventListener('touchmove',_recordNonMessageScrollIntent,{capture:true,passive:true});
|
||||
@@ -2373,6 +2414,7 @@ if(typeof document!=='undefined'){
|
||||
// prevent the new chat's first scroll comparing against the previous chat's
|
||||
// scrollTop (Opus stage-302 SHOULD-FIX, #1731 follow-up).
|
||||
function _resetScrollDirectionTracker(){
|
||||
_clearNewMessageScrollCue();
|
||||
_lastScrollTop=null;
|
||||
_messageUserUnpinned=false;
|
||||
_scrollPinned=true;
|
||||
@@ -2380,6 +2422,7 @@ function _resetScrollDirectionTracker(){
|
||||
_touchStartY=null;
|
||||
}
|
||||
function _resetStreamScrollFollow(){
|
||||
_clearNewMessageScrollCue();
|
||||
_messageUserUnpinned=false;
|
||||
_scrollPinned=true;
|
||||
_nearBottomCount=0;
|
||||
@@ -2495,9 +2538,9 @@ if(typeof window!=='undefined'){
|
||||
_nearBottomCount=0;
|
||||
_scrollPinned=false;
|
||||
}
|
||||
const btn=$('scrollToBottomBtn');
|
||||
if(nearBottom) _clearNewMessageScrollCue();
|
||||
const showBottomButton=!_scrollPinned && el.scrollHeight-top-el.clientHeight>80;
|
||||
if(btn) btn.style.display=showBottomButton?'flex':'none';
|
||||
_syncScrollToBottomCue(showBottomButton,{newMessage:_newMessageCueVisible});
|
||||
if(typeof _updateSessionStartJumpButton==='function') _updateSessionStartJumpButton();
|
||||
// Prefetch older messages before the reader hits the hard top. Prepending
|
||||
// then preserving scrollTop is seamless only if there is runway left for
|
||||
@@ -2992,6 +3035,7 @@ function scrollIfPinned(){
|
||||
_settleMessageScrollToBottom(false);
|
||||
}
|
||||
function scrollToBottom(){
|
||||
_clearNewMessageScrollCue();
|
||||
_scrollPinned=true;
|
||||
_messageUserUnpinned=false;
|
||||
// Write the first bottom position synchronously. A final renderMessages()
|
||||
@@ -3000,8 +3044,7 @@ function scrollToBottom(){
|
||||
// them before the viewport ever reaches the bottom.
|
||||
_setMessageScrollToBottom();
|
||||
_settleMessageScrollToBottom(true);
|
||||
const btn=$('scrollToBottomBtn');
|
||||
if(btn) btn.style.display='none';
|
||||
_syncScrollToBottomCue(false,{newMessage:false});
|
||||
if(typeof _updateSessionStartJumpButton==='function') _updateSessionStartJumpButton();
|
||||
}
|
||||
|
||||
@@ -7694,6 +7737,7 @@ function _captureMessageScrollSnapshot(){
|
||||
return {
|
||||
top:el.scrollTop,
|
||||
bottom,
|
||||
scrollHeight:el.scrollHeight,
|
||||
pinned:_shouldFollowMessagesOnDomReplace(),
|
||||
userUnpinned:_messageUserUnpinned,
|
||||
};
|
||||
@@ -7741,8 +7785,17 @@ function _scrollAfterMessageRender(preserveScroll, scrollSnapshot){
|
||||
// pinned users stay at bottom; users who manually scrolled up get their
|
||||
// pre-render scrollTop restored after the DOM replacement.
|
||||
if(preserveScroll){
|
||||
// Keep master's follow heuristic for pinned / still-near-bottom users:
|
||||
// _followMessagesAfterDomReplace() does a FORCED scrollToBottom() (synchronous
|
||||
// bottom write + forced settle), so the final settled response can't leave a
|
||||
// pinned reader a few lines short. Only genuinely-scrolled-up (unpinned, not
|
||||
// near bottom) users fall through to keep their position and get the
|
||||
// new-message cue. (Using scrollIfPinned() here instead would skip the forced
|
||||
// write unless distance>500 and let the DOM-rebuild scroll event cancel the
|
||||
// delayed settles — Codex CORE catch on #3631.)
|
||||
if(_followMessagesAfterDomReplace()) return;
|
||||
_restoreMessageScrollSnapshot(scrollSnapshot);
|
||||
_maybeShowNewMessageScrollCue(scrollSnapshot);
|
||||
return;
|
||||
}
|
||||
if(S.activeStreamId){
|
||||
|
||||
@@ -57,8 +57,8 @@ def test_render_messages_preserve_scroll_option_uses_user_pin_state_not_stream_l
|
||||
assert "const preserveScroll=!!(options&&options.preserveScroll);" in render_body
|
||||
assert "_scrollAfterMessageRender(preserveScroll, scrollSnapshot);" in render_body
|
||||
assert "const scrollSnapshot=preserveScroll?_captureMessageScrollSnapshot():null" in render_body
|
||||
assert "if(_followMessagesAfterDomReplace()) return;" in scroll_helper
|
||||
assert "_restoreMessageScrollSnapshot(scrollSnapshot);" in scroll_helper
|
||||
assert "if(preserveScroll){\n // Keep master's follow heuristic" in scroll_helper
|
||||
assert "if(_followMessagesAfterDomReplace()) return;\n _restoreMessageScrollSnapshot(scrollSnapshot);\n _maybeShowNewMessageScrollCue(scrollSnapshot);\n return;\n }" in scroll_helper
|
||||
assert "_shouldFollowMessagesOnDomReplace()" in follow_helper
|
||||
assert "scrollToBottom();" in follow_helper
|
||||
assert "if(S.activeStreamId){\n scrollIfPinned();\n return;\n }" in scroll_helper
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
I18N_JS = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _function_body(src: str, signature: str) -> str:
|
||||
start = src.index(signature)
|
||||
brace = src.index("{", start)
|
||||
depth = 0
|
||||
for i in range(brace, len(src)):
|
||||
if src[i] == "{":
|
||||
depth += 1
|
||||
elif src[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[start : i + 1]
|
||||
raise AssertionError(f"function body not found: {signature}")
|
||||
|
||||
|
||||
def _scroll_listener_block() -> str:
|
||||
start = UI_JS.index("el.addEventListener('scroll'")
|
||||
return UI_JS[start : UI_JS.index("})();", start)]
|
||||
|
||||
|
||||
def test_preserve_scroll_unpinned_branch_shows_new_message_cue_after_restore():
|
||||
helper = _function_body(UI_JS, "function _scrollAfterMessageRender")
|
||||
|
||||
# The preserve-scroll branch keeps master's forced follow path for pinned /
|
||||
# near-bottom users (no regression on settled-response bottom-pinning), and
|
||||
# only the genuinely-scrolled-up cohort restores their viewport + gets the
|
||||
# new-message cue. (Codex CORE catch: scrollIfPinned() in the pinned branch
|
||||
# could leave a pinned reader short of the settled response.)
|
||||
assert "if(_followMessagesAfterDomReplace()) return;" in helper
|
||||
assert "_restoreMessageScrollSnapshot(scrollSnapshot);" in helper
|
||||
assert helper.index("_restoreMessageScrollSnapshot(scrollSnapshot)") < helper.index(
|
||||
"_maybeShowNewMessageScrollCue(scrollSnapshot)"
|
||||
)
|
||||
# The cue is only shown on the non-follow (restore) path, after the restore.
|
||||
assert helper.index("_followMessagesAfterDomReplace()") < helper.index(
|
||||
"_maybeShowNewMessageScrollCue(scrollSnapshot)"
|
||||
)
|
||||
assert helper.count("_maybeShowNewMessageScrollCue(scrollSnapshot)") == 1
|
||||
|
||||
|
||||
def test_scroll_cue_uses_growth_below_restored_viewport():
|
||||
maybe = _function_body(UI_JS, "function _maybeShowNewMessageScrollCue")
|
||||
sync = _function_body(UI_JS, "function _syncScrollToBottomCue")
|
||||
|
||||
assert "el.scrollHeight>previousHeight+24" in maybe
|
||||
assert "distance>80" in maybe
|
||||
assert "_showNewMessageScrollCue()" in maybe
|
||||
assert "scroll-to-bottom-btn--new-message" in sync
|
||||
assert "session_new_message" in sync
|
||||
assert "session_jump_end" in sync
|
||||
|
||||
|
||||
def test_click_near_bottom_and_resets_clear_new_message_cue():
|
||||
scroll = _function_body(UI_JS, "function scrollToBottom")
|
||||
reset_direction = _function_body(UI_JS, "function _resetScrollDirectionTracker")
|
||||
reset_stream = _function_body(UI_JS, "function _resetStreamScrollFollow")
|
||||
listener = _scroll_listener_block()
|
||||
|
||||
assert scroll.index("_clearNewMessageScrollCue();") < scroll.index("_scrollPinned=true")
|
||||
assert "if(nearBottom) _clearNewMessageScrollCue();" in listener
|
||||
assert "_syncScrollToBottomCue(showBottomButton,{newMessage:_newMessageCueVisible})" in listener
|
||||
assert "_clearNewMessageScrollCue();" in reset_direction
|
||||
assert "_clearNewMessageScrollCue();" in reset_stream
|
||||
|
||||
|
||||
def test_new_message_cue_i18n_keys_exist_in_locale_blocks():
|
||||
assert I18N_JS.count("session_new_message:") >= 8
|
||||
assert I18N_JS.count("session_new_message_label:") >= 8
|
||||
assert "session_new_message: 'New message'" in I18N_JS
|
||||
assert "session_new_message_label: 'New message available, jump to end'" in I18N_JS
|
||||
|
||||
|
||||
def test_new_message_cue_has_stable_pill_styling():
|
||||
assert ".scroll-to-bottom-btn.scroll-to-bottom-btn--new-message" in STYLE_CSS
|
||||
assert "max-width:min(220px,calc(100% - 40px))" in STYLE_CSS
|
||||
assert ".scroll-to-bottom-btn.scroll-to-bottom-btn--new-message .session-jump-btn__text" in STYLE_CSS
|
||||
@@ -132,8 +132,8 @@ class TestScrollPinningFix:
|
||||
assert scroll_listener_start != -1, "scroll event listener not found"
|
||||
# After #1360 fix, the nearBottom + btn logic lives inside an rAF
|
||||
# callback — extend search window to cover the full listener block.
|
||||
listener_block = UI_JS[scroll_listener_start:scroll_listener_start + 1200]
|
||||
assert "scrollToBottomBtn" in listener_block, (
|
||||
listener_block = UI_JS[scroll_listener_start:scroll_listener_start + 1400]
|
||||
assert "_syncScrollToBottomCue(showBottomButton" in listener_block, (
|
||||
"Scroll listener must show/hide scrollToBottomBtn based on _scrollPinned (#677)"
|
||||
)
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ def test_session_jump_buttons_are_opt_in_and_keep_existing_bottom_button():
|
||||
|
||||
scroll_listener = UI_JS[UI_JS.index("el.addEventListener('scroll'") : UI_JS.index("})();", UI_JS.index("el.addEventListener('scroll'"))]
|
||||
assert "const showBottomButton=!_scrollPinned && el.scrollHeight-top-el.clientHeight>80" in scroll_listener
|
||||
assert "if(btn) btn.style.display=showBottomButton?'flex':'none'" in scroll_listener
|
||||
assert "_syncScrollToBottomCue(showBottomButton,{newMessage:_newMessageCueVisible})" in scroll_listener
|
||||
assert "!_isSessionJumpButtonsEnabled()||_scrollPinned" not in UI_JS
|
||||
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ def test_preserve_scroll_restores_unpinned_viewport_after_dom_rebuild():
|
||||
"replacing transcript DOM, then pass that snapshot to the post-render scroll helper"
|
||||
)
|
||||
assert "if(_followMessagesAfterDomReplace()) return;" in after_render
|
||||
assert "_restoreMessageScrollSnapshot(scrollSnapshot)" in after_render
|
||||
assert "_restoreMessageScrollSnapshot(scrollSnapshot);\n _maybeShowNewMessageScrollCue(scrollSnapshot);" in after_render
|
||||
assert "_shouldFollowMessagesOnDomReplace()" in follow
|
||||
assert "scrollToBottom();" in follow
|
||||
assert "el.scrollTop=Math.max(0,Math.min(Number(snapshot.top)||0,maxTop))" in restore
|
||||
|
||||
Reference in New Issue
Block a user