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 = `