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:
1 parent
0af629fbb9
commit
97f92b3a4b
3 files changed
+81
-35
No files matched your search
+26
-26
@@ -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
@@ -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>`;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user