Merge pull request #2303 into stage-359

Add assistant question jump buttons (franksong2702, fixes #2246)

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Hermes Agent
2026-05-15 14:55:15 +00:00
7 changed files with 150 additions and 1 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

+24
View File
@@ -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)',
+33
View File
@@ -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;
+51 -1
View File
@@ -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 `<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>`;
}
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 ? `<span class="msg-time" title="${esc(tsTitle)}">${tsTime}</span>` : '';
const footHtml = `<div class="msg-foot">${timeHtml}<span class="msg-actions">${editBtn}${ttsBtn}${forkBtn}${copyBtn}${retryBtn}</span></div>`;
const questionJumpBtn = (!isUser&&!m._live&&isTurnFinalAssistant)
? _questionJumpButtonHtml(questionRawIdxByAssistantRawIdx.get(rawIdx))
: '';
const footHtml = `<div class="msg-foot">${timeHtml}<span class="msg-actions">${editBtn}${ttsBtn}${forkBtn}${copyBtn}${retryBtn}</span>${questionJumpBtn}</div>`;
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();
+42
View File
@@ -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