fix(#3852): jump-to-question button targets response start

The per-turn jump button now scrolls to the START OF THE RESPONSE (the assistant
segment) instead of the user's question, with a fail-safe fallback to the question
row when the assistant segment isn't found. Label updated to 'to response' across
all 13 locales.

Closes #3852.

Co-authored-by: Rod Boev <rod.boev@gmail.com>
This commit is contained in:
nesquena-hermesandRod Boev committed 2026-06-10 19:48:41 +00:00
1 parent 0af629fbb9
commit 97f92b3a4b
3 files changed
+81 -35

No files matched your search

+26 -26
View File
@@ -172,8 +172,8 @@ const LOCALES = {
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',
jump_to_question: 'to response',
jump_to_question_label: 'Jump to the start of this response',
queued_label: 'Sends after response',
queued_count: (n) => n === 1 ? '1 queued' : `${n} queued`,
queued_cancel: 'Cancel queued message',
@@ -1576,8 +1576,8 @@ const LOCALES = {
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',
jump_to_question: 'alla risposta',
jump_to_question_label: 'Vai all\'inizio di questa risposta',
queued_label: 'Inviato dopo la risposta',
queued_count: (n) => n === 1 ? '1 in coda' : `${n} in coda`,
queued_cancel: 'Annulla messaggio in coda',
@@ -2972,8 +2972,8 @@ const LOCALES = {
session_jump_end_label: 'セッションの末尾へ移動',
session_new_message: '新着メッセージ',
session_new_message_label: '新着メッセージがあります。末尾へ移動',
jump_to_question: '質問へ',
jump_to_question_label: 'この回答の質問へ移動',
jump_to_question: '回答へ',
jump_to_question_label: 'この回答の先頭へ移動',
queued_label: '応答後に送信',
queued_count: (n) => `${n} 件キュー中`,
queued_cancel: 'キューに入れたメッセージをキャンセル',
@@ -4345,8 +4345,8 @@ const LOCALES = {
session_jump_end_label: 'Перейти к концу сессии',
session_new_message: 'Новое сообщение',
session_new_message_label: 'Новое сообщение доступно, перейти к концу',
jump_to_question: 'к вопросу',
jump_to_question_label: 'Перейти к вопросу для этого ответа',
jump_to_question: 'к ответу',
jump_to_question_label: 'Перейти к началу этого ответа',
queued_label: 'Отправить после ответа',
queued_count: (n) => n === 1 ? '1 в очереди' : `${n} в очереди`,
queued_cancel: 'Отменить сообщение',
@@ -5686,8 +5686,8 @@ const LOCALES = {
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',
jump_to_question: 'a la respuesta',
jump_to_question_label: 'Saltar al inicio de esta respuesta',
queued_label: 'Enviar después de la respuesta',
queued_count: (n) => n === 1 ? '1 en cola' : `${n} en cola`,
queued_cancel: 'Cancelar mensaje en cola',
@@ -7018,8 +7018,8 @@ const LOCALES = {
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',
jump_to_question: 'zur Antwort',
jump_to_question_label: 'Zum Anfang dieser Antwort springen',
queued_label: 'Wird nach Antwort gesendet',
queued_count: (n) => n === 1 ? '1 in Warteschlange' : `${n} in Warteschlange`,
queued_cancel: 'Nachricht abbrechen',
@@ -8354,8 +8354,8 @@ const LOCALES = {
session_jump_end_label: '跳转到会话结尾',
session_new_message: '新消息',
session_new_message_label: '有新消息,跳转到结尾',
jump_to_question: '回到问题',
jump_to_question_label: '跳转到这条回答对应的问题',
jump_to_question: '回到回答',
jump_to_question_label: '跳转到这条回答的开头',
queued_label: '响应后发送',
queued_count: (n) => n === 1 ? '1 条排队' : `${n} 条排队`,
queued_cancel: '取消排队消息',
@@ -9709,8 +9709,8 @@ const LOCALES = {
session_jump_end_label: '跳至對話結尾',
session_new_message: '新訊息',
session_new_message_label: '有新訊息,跳至結尾',
jump_to_question: '回到問題',
jump_to_question_label: '跳至這則回答對應的問題',
jump_to_question: '回到回答',
jump_to_question_label: '跳至這則回答的開頭',
queued_label: '回應後傳送',
queued_count: (n) => n === 1 ? '1 已排隊' : `${n} 已排隊`,
queued_cancel: '取消排隊訊息',
@@ -11015,8 +11015,8 @@ const LOCALES = {
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',
jump_to_question: 'para a resposta',
jump_to_question_label: 'Ir para o início desta resposta',
queued_label: 'Envia após a resposta',
queued_count: (n) => n === 1 ? '1 na fila' : `${n} na fila`,
queued_cancel: 'Cancelar mensagem na fila',
@@ -12293,8 +12293,8 @@ const LOCALES = {
session_jump_end_label: '세션 끝으로 이동',
session_new_message: '새 메시지',
session_new_message_label: '새 메시지가 있습니다, 끝으로 이동',
jump_to_question: '질문으로',
jump_to_question_label: '이 응답의 질문으로 이동',
jump_to_question: '응답으로',
jump_to_question_label: '이 응답의 시작으로 이동',
queued_label: 'Sends after response',
queued_count: (n) => n === 1 ? '1 queued' : `${n} queued`,
queued_cancel: 'Cancel queued message',
@@ -13695,8 +13695,8 @@ const LOCALES = {
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',
jump_to_question: 'à la réponse',
jump_to_question_label: 'Aller au début de cette réponse',
queued_label: 'Envoie après réponse',
queued_cancel: 'Annuler le message en file d\'attente',
model_unavailable: '(indisponible)',
@@ -15024,8 +15024,8 @@ const LOCALES = {
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',
jump_to_question: 'yanıta',
jump_to_question_label: 'Bu yanıtın başına git',
queued_label: 'Yanıttan sonra gönderilir',
queued_count: (n) => n === 1 ? '1 queued' : `${n} queued`,
queued_cancel: 'Sıraya alınmış mesajı iptal et',
@@ -16421,8 +16421,8 @@ const LOCALES = {
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',
jump_to_question: 'do odpowiedzi',
jump_to_question_label: 'Przejdź do początku tej odpowiedzi',
queued_label: 'Zostanie wysłane po odpowiedzi',
queued_count: (n) => n === 1 ? '1 w kolejce' : `${n} w kolejce`,
queued_cancel: 'Anuluj wiadomości w kolejce',
+26 -6
View File
@@ -497,11 +497,12 @@ function _userMessageDomId(rawIdx){
return `msg-user-${rawIdx}`;
}
function _questionJumpButtonHtml(questionRawIdx){
function _questionJumpButtonHtml(questionRawIdx, assistantRawIdx){
if(typeof questionRawIdx!=='number'||questionRawIdx<0) return '';
const label=t('jump_to_question')||'Question';
const title=t('jump_to_question_label')||'Jump to the question for this response';
return `<button class="msg-question-jump-btn" type="button" title="${esc(title)}" aria-label="${esc(title)}" onclick="jumpToTurnQuestion(${questionRawIdx})"><span aria-hidden="true">↑</span><span>${esc(label)}</span></button>`;
const label=t('jump_to_question')||'Response';
const title=t('jump_to_question_label')||'Jump to the start of this response';
const aIdx=(typeof assistantRawIdx==='number'&&assistantRawIdx>=0)?assistantRawIdx:-1;
return `<button class="msg-question-jump-btn" type="button" title="${esc(title)}" aria-label="${esc(title)}" onclick="jumpToTurnQuestion(${questionRawIdx},${aIdx})"><span aria-hidden="true">↑</span><span>${esc(label)}</span></button>`;
}
function _highlightQuestionRow(row){
@@ -512,10 +513,25 @@ function _highlightQuestionRow(row){
window.setTimeout(()=>row.classList.remove('msg-question-highlight'),1800);
}
async function jumpToTurnQuestion(questionRawIdx){
async function jumpToTurnQuestion(questionRawIdx, assistantRawIdx){
const container=$('messages');
if(!container||typeof questionRawIdx!=='number'||questionRawIdx<0) return;
const scrollToTarget=()=>{
const hasAssistant=typeof assistantRawIdx==='number'&&assistantRawIdx>=0;
if(hasAssistant){
// A single assistant rawIdx can render multiple segment nodes — some hidden
// (assistant-segment-worklog-source / assistant-segment-anchor are display:none).
// scrollIntoView() on a hidden node silently no-ops, so only treat a VISIBLE
// segment (getClientRects().length>0) as a successful target; otherwise fall
// through to the question-row fallback rather than suppressing it. (#3934)
const segs=container.querySelectorAll('[data-msg-idx="'+assistantRawIdx+'"]');
for(const seg of segs){
if(seg.getClientRects().length>0){
seg.scrollIntoView({block:'start',behavior:'smooth'});
return true;
}
}
}
const row=document.getElementById(_userMessageDomId(questionRawIdx));
if(!row) return false;
row.scrollIntoView({block:'center',behavior:'smooth'});
@@ -8175,6 +8191,10 @@ function renderMessages(options){
if(role==='user') lastQuestionRawIdx=entry.rawIdx;
else if(role==='assistant') questionRawIdxByAssistantRawIdx.set(entry.rawIdx,lastQuestionRawIdx);
}
const assistantRawIdxByQuestionRawIdx=new Map();
for(const [aIdx,qIdx] of questionRawIdxByAssistantRawIdx){
if(!assistantRawIdxByQuestionRawIdx.has(qIdx)) assistantRawIdxByQuestionRawIdx.set(qIdx,aIdx);
}
// #3709 (defect B): build a per-turn combined visible-answer text so the
// thinking echo-strip can de-dupe a thinking-only message (whose own visible
// body is empty) against the answer prose carried by a SIBLING message in the
@@ -8329,7 +8349,7 @@ function renderMessages(options){
// user loses the navigation affordance.
const _qJumpTarget=(!isUser&&!m._live)?questionRawIdxByAssistantRawIdx.get(rawIdx):undefined;
const questionJumpBtn = (_qJumpTarget!==undefined&&_qJumpTarget!==null)
? _questionJumpButtonHtml(_qJumpTarget)
? _questionJumpButtonHtml(_qJumpTarget, assistantRawIdxByQuestionRawIdx.get(_qJumpTarget)??rawIdx)
: '';
const footHtml = `<div class="msg-foot">${timeHtml}<span class="msg-actions">${editBtn}${ttsBtn}${forkBtn}${copyBtn}${retryBtn}</span>${questionJumpBtn}</div>`;
+29 -3
View File
@@ -9,8 +9,8 @@ I18N_JS = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
def test_assistant_footer_gets_completed_turn_question_jump_button():
assert "function _questionJumpButtonHtml(questionRawIdx)" in UI_JS
assert "function jumpToTurnQuestion(questionRawIdx)" in UI_JS
assert "function _questionJumpButtonHtml(questionRawIdx, assistantRawIdx)" in UI_JS
assert "function jumpToTurnQuestion(questionRawIdx, assistantRawIdx)" in UI_JS
assert "const questionRawIdxByAssistantRawIdx=new Map()" in UI_JS
assert "questionRawIdxByAssistantRawIdx.set(entry.rawIdx,lastQuestionRawIdx)" in UI_JS
assert "row.id=_userMessageDomId(rawIdx)" in UI_JS
@@ -22,10 +22,19 @@ def test_assistant_footer_gets_completed_turn_question_jump_button():
# resolved target instead of isTurnFinalAssistant.
assert "const _qJumpTarget=(!isUser&&!m._live)?questionRawIdxByAssistantRawIdx.get(rawIdx):undefined;" in UI_JS
assert "const questionJumpBtn = (_qJumpTarget!==undefined&&_qJumpTarget!==null)" in UI_JS
assert "_questionJumpButtonHtml(_qJumpTarget)" in UI_JS
assert "_questionJumpButtonHtml(_qJumpTarget, assistantRawIdxByQuestionRawIdx.get(_qJumpTarget)??rawIdx)" in UI_JS
assert "msg-question-jump-btn" in UI_JS
def test_multi_segment_turn_jumps_to_first_assistant_segment():
# #3852: the reverse map assistantRawIdxByQuestionRawIdx resolves the FIRST
# assistant segment for a given question so multi-step turns (tool_call ->
# assistant -> tool_call -> assistant) scroll to the start of the response.
assert "const assistantRawIdxByQuestionRawIdx=new Map()" in UI_JS
assert "if(!assistantRawIdxByQuestionRawIdx.has(qIdx)) assistantRawIdxByQuestionRawIdx.set(qIdx,aIdx)" in UI_JS
assert "assistantRawIdxByQuestionRawIdx.get(_qJumpTarget)" in UI_JS
def test_question_jump_expands_windowed_history_and_highlights_question():
assert "_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount())" in UI_JS
assert "renderMessages({ preserveScroll:true })" in UI_JS
@@ -46,3 +55,20 @@ def test_question_jump_button_is_quiet_and_hidden_on_mobile():
def test_question_jump_text_is_localized():
for key in ("jump_to_question", "jump_to_question_label"):
assert I18N_JS.count(f"{key}:") >= 12
def test_question_jump_skips_hidden_first_segment_and_falls_back():
# #3934 (Codex gate): a single assistant rawIdx can render multiple segment
# nodes, and the first can be display:none (assistant-segment-worklog-source /
# assistant-segment-anchor). scrollIntoView() on a hidden node silently no-ops,
# so the jump must only treat a VISIBLE segment as a successful target and
# otherwise fall through to the question-row fallback instead of suppressing it.
# Guard against regressing back to the unconditional first-match return.
assert "querySelectorAll('[data-msg-idx=\"'+assistantRawIdx+'\"]')" in UI_JS
assert "seg.getClientRects().length>0" in UI_JS
# The single-element querySelector + unconditional return must be gone.
assert "const seg=hasAssistant?container.querySelector('[data-msg-idx=\"'+assistantRawIdx+'\"]'):null;" not in UI_JS
# display:none on the hidden segment classes is what makes the guard necessary.
assert ".assistant-segment-worklog-source{" in STYLE_CSS
assert ".assistant-segment-anchor { display: none; }" in STYLE_CSS