Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions static/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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: '応答後に送信',
Expand Down Expand Up @@ -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: 'Отправить после ответа',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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: '响应后发送',
Expand Down Expand Up @@ -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: '回應後傳送',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);}
Expand Down
61 changes: 57 additions & 4 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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});
Expand All @@ -2373,13 +2414,15 @@ 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;
_nearBottomCount=0;
_touchStartY=null;
}
function _resetStreamScrollFollow(){
_clearNewMessageScrollCue();
_messageUserUnpinned=false;
_scrollPinned=true;
_nearBottomCount=0;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2992,6 +3035,7 @@ function scrollIfPinned(){
_settleMessageScrollToBottom(false);
}
function scrollToBottom(){
_clearNewMessageScrollCue();
_scrollPinned=true;
_messageUserUnpinned=false;
// Write the first bottom position synchronously. A final renderMessages()
Expand All @@ -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();
}

Expand Down Expand Up @@ -7694,6 +7737,7 @@ function _captureMessageScrollSnapshot(){
return {
top:el.scrollTop,
bottom,
scrollHeight:el.scrollHeight,
pinned:_shouldFollowMessagesOnDomReplace(),
userUnpinned:_messageUserUnpinned,
};
Expand Down Expand Up @@ -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){
Expand Down
4 changes: 2 additions & 2 deletions tests/test_issue1690_scroll_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines 58 to 59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Brittle literal-whitespace assertions on comment text

Both new assertions match exact whitespace and the inline comment "// Keep master's follow heuristic". If the block is ever reformatted or the comment is updated (e.g. issue number changes), this test will fail despite the code being functionally correct. The behavioral invariant being tested — that _followMessagesAfterDomReplace() comes before _restoreMessageScrollSnapshot and _maybeShowNewMessageScrollCue — could be checked with three independent in checks (already done in test_issue3545_streaming_scroll_cue.py) rather than a single multi-line string that encodes the exact comment wording.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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
Expand Down
84 changes: 84 additions & 0 deletions tests/test_issue3545_streaming_scroll_cue.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +75 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Pinning the count to == 13 matches the 13-locale parity the maintainer explicitly achieved and ensures future locale additions don't silently regress.

Suggested change
assert I18N_JS.count("session_new_message:") >= 8
assert I18N_JS.count("session_new_message_label:") >= 8
assert I18N_JS.count("session_new_message:") == 13
assert I18N_JS.count("session_new_message_label:") == 13

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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
Comment on lines +80 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 i18n locale count assertion is a weak lower bound

I18N_JS.count("session_new_message:") >= 8 passes even if only 8 of the 13 locales are present. The PR explicitly fixed a missing Polish locale to reach 13/13, so it's worth locking in that count — == 13 would catch any future locale being accidentally dropped.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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
4 changes: 2 additions & 2 deletions tests/test_issue677.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
)

Expand Down
2 changes: 1 addition & 1 deletion tests/test_session_jump_buttons.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading
Loading