Release v0.51.334 — Release KX (new-message cue when scrolled up, #3631) (#3849)

* 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:
nesquena-hermes
2026-06-08 15:58:13 -07:00
committed by GitHub
parent 52993af88a
commit a71dbcd6bb
9 changed files with 180 additions and 10 deletions
+5
View File
@@ -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
+26
View File
@@ -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',
+2
View File
@@ -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
View File
@@ -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){
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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)"
)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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