diff --git a/docs/pr-media/2246/after-question-highlight.png b/docs/pr-media/2246/after-question-highlight.png new file mode 100644 index 00000000..b3108b9a Binary files /dev/null and b/docs/pr-media/2246/after-question-highlight.png differ diff --git a/docs/pr-media/2246/after-question-jump-button.png b/docs/pr-media/2246/after-question-jump-button.png new file mode 100644 index 00000000..c59aff02 Binary files /dev/null and b/docs/pr-media/2246/after-question-jump-button.png differ diff --git a/docs/pr-media/2246/before-no-question-jump.png b/docs/pr-media/2246/before-no-question-jump.png new file mode 100644 index 00000000..b6091849 Binary files /dev/null and b/docs/pr-media/2246/before-no-question-jump.png differ diff --git a/static/i18n.js b/static/i18n.js index bb4f30fa..0e95decf 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -150,6 +150,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', + jump_to_question: 'to question', + jump_to_question_label: 'Jump to the question for this response', queued_label: 'Sends after response', queued_count: (n) => n === 1 ? '1 queued' : `${n} queued`, queued_cancel: 'Cancel queued message', @@ -1337,6 +1339,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', + jump_to_question: 'alla domanda', + jump_to_question_label: 'Vai alla domanda 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', @@ -2516,6 +2520,8 @@ const LOCALES = { session_jump_start_label: 'セッションの先頭へ移動', session_jump_end: '末尾', session_jump_end_label: 'セッションの末尾へ移動', + jump_to_question: '質問へ', + jump_to_question_label: 'この回答の質問へ移動', queued_label: '応答後に送信', queued_count: (n) => `${n} 件キュー中`, queued_cancel: 'キューに入れたメッセージをキャンセル', @@ -3678,6 +3684,8 @@ const LOCALES = { session_jump_start_label: 'Перейти к началу сессии', session_jump_end: 'Конец', session_jump_end_label: 'Перейти к концу сессии', + jump_to_question: 'к вопросу', + jump_to_question_label: 'Перейти к вопросу для этого ответа', queued_label: 'Отправить после ответа', queued_count: (n) => n === 1 ? '1 в очереди' : `${n} в очереди`, queued_cancel: 'Отменить сообщение', @@ -4798,6 +4806,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', + 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', queued_count: (n) => n === 1 ? '1 en cola' : `${n} en cola`, queued_cancel: 'Cancelar mensaje en cola', @@ -5914,6 +5924,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', + jump_to_question: 'zur Frage', + jump_to_question_label: 'Zur Frage dieser Antwort springen', queued_label: 'Wird nach Antwort gesendet', queued_count: (n) => n === 1 ? '1 in Warteschlange' : `${n} in Warteschlange`, queued_cancel: 'Nachricht abbrechen', @@ -7034,6 +7046,8 @@ const LOCALES = { session_jump_start_label: '跳转到会话开头', session_jump_end: '结尾', session_jump_end_label: '跳转到会话结尾', + jump_to_question: '回到问题', + jump_to_question_label: '跳转到这条回答对应的问题', queued_label: '响应后发送', queued_count: (n) => n === 1 ? '1 条排队' : `${n} 条排队`, queued_cancel: '取消排队消息', @@ -8142,6 +8156,8 @@ const LOCALES = { session_jump_start_label: '跳至會話開頭', session_jump_end: '結尾', session_jump_end_label: '跳至會話結尾', + jump_to_question: '回到問題', + jump_to_question_label: '跳至這則回答對應的問題', model_unavailable: '\uff08\u4e0d\u53ef\u7528\uff09', model_unavailable_title: '\u6b64\u6a21\u578b\u5df2\u7d93\u4e0d\u5728\u7576\u524d provider \u5217\u8868\u4e2d', provider_mismatch_warning: (m,p)=>`\"${m}\" \u53ef\u80fd\u7121\u6cd5\u5728\u7576\u524d\u914d\u7f6e\u7684\u63d0\u4f9b\u8005 (${p}) \u4e0b\u904b\u4f5c\u3002\u5c1a\u9001\uff0c\u6216\u5728\u7d42\u7aef\u57f7\u884c \`hermes model\` \u5207\u63db\u3002`, @@ -8595,6 +8611,8 @@ const LOCALES = { session_jump_start_label: '跳至會話開頭', session_jump_end: '結尾', session_jump_end_label: '跳至會話結尾', + jump_to_question: '回到問題', + jump_to_question_label: '跳至這則回答對應的問題', onboarding_api_key_help_prefix: '\u900f\u904e\u4ee5\u4e0b\u65b9\u5f0f\u5132\u5b58\u70ba Hermes .env \u6a94\u6848\u4e2d\u7684\u6a5f\u5bc6', onboarding_api_key_label: 'API \u91d1\u9470', onboarding_api_key_placeholder: '\u7559\u7a7a\u4ee5\u4fdd\u7559\u5df2\u5132\u5b58\u7684\u91d1\u9470', @@ -9286,6 +9304,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', + jump_to_question: 'para a pergunta', + jump_to_question_label: 'Ir para a pergunta 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', @@ -10352,6 +10372,8 @@ const LOCALES = { session_jump_start_label: '세션 시작으로 이동', session_jump_end: '끝', session_jump_end_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', @@ -11536,6 +11558,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', + jump_to_question: 'à la question', + jump_to_question_label: 'Aller à la question de cette réponse', queued_label: 'Envoie après réponse', queued_cancel: 'Annuler le message en file d\'attente', model_unavailable: '(indisponible)', diff --git a/static/style.css b/static/style.css index 5f700486..33111e90 100644 --- a/static/style.css +++ b/static/style.css @@ -3186,6 +3186,39 @@ main.main.showing-logs > #mainLogs{display:flex;} } .msg-foot .msg-actions { opacity: 1; margin-left: 0; } .msg-foot .msg-time { font-size: 10.5px; opacity: .75; } +.msg-question-jump-btn { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: auto; + padding: 3px 8px; + border: 1px solid var(--border-subtle); + border-radius: 999px; + background: var(--surface-subtle); + color: var(--muted); + font-size: 11px; + line-height: 1.3; + cursor: pointer; + transition: color .12s, border-color .12s, background .12s; +} +.msg-question-jump-btn:hover, +.msg-question-jump-btn:focus-visible { + color: var(--text); + border-color: var(--border); + background: var(--hover-bg); + outline: none; +} +.msg-question-highlight .msg-body { + animation: question-highlight-pulse 1.6s ease-out; +} +@keyframes question-highlight-pulse { + 0% { box-shadow: 0 0 0 0 var(--focus-ring); } + 35% { box-shadow: 0 0 0 5px var(--focus-ring); } + 100% { box-shadow: 0 0 0 0 rgba(0,0,0,0); } +} +@media (max-width: 600px) { + .msg-question-jump-btn { display: none; } +} .msg-foot-with-usage { justify-content: flex-start; gap: 8px; diff --git a/static/ui.js b/static/ui.js index 0583c902..98b81acc 100644 --- a/static/ui.js +++ b/static/ui.js @@ -350,6 +350,43 @@ async function jumpToSessionStart(){ } } +function _userMessageDomId(rawIdx){ + return `msg-user-${rawIdx}`; +} + +function _questionJumpButtonHtml(questionRawIdx){ + 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 ``; +} + +function _highlightQuestionRow(row){ + if(!row) return; + row.classList.remove('msg-question-highlight'); + void row.offsetWidth; + row.classList.add('msg-question-highlight'); + window.setTimeout(()=>row.classList.remove('msg-question-highlight'),1800); +} + +async function jumpToTurnQuestion(questionRawIdx){ + const container=$('messages'); + if(!container||typeof questionRawIdx!=='number'||questionRawIdx<0) return; + const scrollToTarget=()=>{ + const row=document.getElementById(_userMessageDomId(questionRawIdx)); + if(!row) return false; + row.scrollIntoView({block:'center',behavior:'smooth'}); + _highlightQuestionRow(row); + return true; + }; + if(scrollToTarget()) return; + if(_messageHiddenBeforeCount()>0){ + _messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount()); + renderMessages({ preserveScroll:true }); + requestAnimationFrame(scrollToTarget); + } +} + const DASHBOARD_STATUS_TTL_MS=60000; let _dashboardStatusCache=null; let _dashboardStatusFetchedAt=0; @@ -5288,6 +5325,13 @@ function renderMessages(options){ } let _prevSepKey=null; let currentAssistantTurn=null; + const questionRawIdxByAssistantRawIdx=new Map(); + let lastQuestionRawIdx=-1; + for(const entry of visWithIdx){ + const role=entry&&entry.m&&entry.m.role; + if(role==='user') lastQuestionRawIdx=entry.rawIdx; + else if(role==='assistant') questionRawIdxByAssistantRawIdx.set(entry.rawIdx,lastQuestionRawIdx); + } const assistantSegments=new Map(); const assistantThinking=new Map(); const userRows=new Map(); @@ -5340,6 +5384,8 @@ function renderMessages(options){ const isUser=m.role==='user'; const displayContent=isUser?_stripWorkspaceDisplayPrefix(content):content; const isLastAssistant=!isUser&&vi===renderVisWithIdx.length-1; + const nextRendered=renderVisWithIdx[vi+1]; + const isTurnFinalAssistant=!isUser&&(!nextRendered||!nextRendered.m||nextRendered.m.role!=='assistant'); let filesHtml=''; if(m.attachments&&m.attachments.length){ // Static regression tests intentionally look for msg-media-img/msg-file-badge near this branch. @@ -5372,7 +5418,10 @@ function renderMessages(options){ const tsTitle=tsVal?(_fmtSv?_fmtSv(new Date(tsVal*1000),{}):new Date(tsVal*1000).toLocaleString()):''; const tsTime=_formatMessageFooterTimestamp(tsVal); const timeHtml = tsTime ? `${tsTime}` : ''; - const footHtml = `
${timeHtml}${editBtn}${ttsBtn}${forkBtn}${copyBtn}${retryBtn}
`; + const questionJumpBtn = (!isUser&&!m._live&&isTurnFinalAssistant) + ? _questionJumpButtonHtml(questionRawIdxByAssistantRawIdx.get(rawIdx)) + : ''; + const footHtml = `
${timeHtml}${editBtn}${ttsBtn}${forkBtn}${copyBtn}${retryBtn}${questionJumpBtn}
`; if(_isContextCompactionMessage(m)){ if(compressionState || referenceNode){ @@ -5392,6 +5441,7 @@ function renderMessages(options){ currentAssistantTurn=null; const row=document.createElement('div'); row.className='msg-row'; + row.id=_userMessageDomId(rawIdx); row.dataset.msgIdx=rawIdx; row.dataset.role='user'; row.dataset.rawText=String(displayContent).trim(); diff --git a/tests/test_issue2246_question_jump.py b/tests/test_issue2246_question_jump.py new file mode 100644 index 00000000..e06ce874 --- /dev/null +++ b/tests/test_issue2246_question_jump.py @@ -0,0 +1,42 @@ +"""Regression coverage for #2246 per-turn jump-to-question buttons.""" + +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") +STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-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 "const questionRawIdxByAssistantRawIdx=new Map()" in UI_JS + assert "questionRawIdxByAssistantRawIdx.set(entry.rawIdx,lastQuestionRawIdx)" in UI_JS + assert "row.id=_userMessageDomId(rawIdx)" in UI_JS + assert "const isTurnFinalAssistant=!isUser&&(!nextRendered||!nextRendered.m||nextRendered.m.role!=='assistant')" in UI_JS + assert "(!isUser&&!m._live&&isTurnFinalAssistant)" in UI_JS + assert "_questionJumpButtonHtml(questionRawIdxByAssistantRawIdx.get(rawIdx))" in UI_JS + assert "msg-question-jump-btn" 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 + assert "row.scrollIntoView({block:'center',behavior:'smooth'})" in UI_JS + assert "_highlightQuestionRow(row)" in UI_JS + assert "msg-question-highlight" in UI_JS + + +def test_question_jump_button_is_quiet_and_hidden_on_mobile(): + assert ".msg-question-jump-btn" in STYLE_CSS + assert "margin-left: auto;" in STYLE_CSS + assert ".msg-question-highlight .msg-body" in STYLE_CSS + assert "@keyframes question-highlight-pulse" in STYLE_CSS + assert "@media (max-width: 600px)" in STYLE_CSS + assert ".msg-question-jump-btn { display: none; }" in STYLE_CSS + + +def test_question_jump_text_is_localized(): + for key in ("jump_to_question", "jump_to_question_label"): + assert I18N_JS.count(f"{key}:") >= 12