diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d2ec2c86..d4bf740be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/static/i18n.js b/static/i18n.js index 9ffb98309..7416fc7a7 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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', diff --git a/static/style.css b/static/style.css index fc40d1670..0ed263a4f 100644 --- a/static/style.css +++ b/static/style.css @@ -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);} diff --git a/static/ui.js b/static/ui.js index a67729193..ff1b20941 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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()-_lastNonMessageScrollIntentMspreviousHeight+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){ diff --git a/tests/test_issue1690_scroll_completion.py b/tests/test_issue1690_scroll_completion.py index f96b410d9..2c03e2465 100644 --- a/tests/test_issue1690_scroll_completion.py +++ b/tests/test_issue1690_scroll_completion.py @@ -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 diff --git a/tests/test_issue3545_streaming_scroll_cue.py b/tests/test_issue3545_streaming_scroll_cue.py new file mode 100644 index 000000000..7b1aa51c5 --- /dev/null +++ b/tests/test_issue3545_streaming_scroll_cue.py @@ -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 diff --git a/tests/test_issue677.py b/tests/test_issue677.py index 3210e0827..0406a40c6 100644 --- a/tests/test_issue677.py +++ b/tests/test_issue677.py @@ -132,8 +132,8 @@ def test_scroll_listener_hides_button_when_pinned(self): 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)" ) diff --git a/tests/test_session_jump_buttons.py b/tests/test_session_jump_buttons.py index 274b61042..fee564760 100644 --- a/tests/test_session_jump_buttons.py +++ b/tests/test_session_jump_buttons.py @@ -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 diff --git a/tests/test_tars_scroll_reset_regressions.py b/tests/test_tars_scroll_reset_regressions.py index ba4543b2c..fc80f6fae 100644 --- a/tests/test_tars_scroll_reset_regressions.py +++ b/tests/test_tars_scroll_reset_regressions.py @@ -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