Add assistant question jump buttons
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Assistant responses now show a small "to question" jump button after the turn completes on desktop. Clicking it scrolls back to the user question that started the turn and briefly highlights that question. The affordance is hidden on narrow mobile viewports to avoid crowding the composer. Refs #2246.
|
||||
|
||||
- **PR #2099** by @dobby-d-elf — Adds an opt-in `Settings → Preferences → Fade text effect` toggle (off by default). When enabled, newly streamed output tokens are revealed through an adaptive playout buffer and animated with an opacity-only fade similar to ChatGPT and other frontier LLM apps. Implementation details: fade locked per stream to avoid mid-stream toggle rewind; reduced-motion users get non-animated text; live cursor hidden while fade is active; custom renderer on `streaming-markdown` parser wraps only newly-appended words; animated spans replace themselves with plain text on `animationend` (no long-lived wrapper buildup in long responses); unsafe streamed `href`/`src` values blocked in fade renderer `set_attr` path. Performance tuning: 200ms base fade duration scaling to 350ms for fast output, 16ms word stagger, 320ms done-drain wait cap, 160 wps visual cap, max 2-3 words/frame, brief pauses after sentence punctuation. Default-off means existing users see no change. 293-line regression test pinning the contract.
|
||||
|
||||
- **PR #2165** by @starship-s — Pooled OpenAI Codex quota status surfaced in the Providers panel. Pre-fix, the Providers page presented Codex quota as if there were only one credential/account state, which was misleading when users authenticate through a credential pool with several usable credentials, temporarily exhausted credentials, failed probes, and different reset windows. Now the active provider quota card includes a credential-pool summary (available / exhausted / failed / checked counts), displays the best currently-available pool windows in the collapsed view as "Best of N", and exposes per-credential detail behind an expandable section. Exhausted credentials are intentionally NOT re-probed while their cooldown is active (matches credential-pool selection behavior, avoids generating failed quota calls from a status page). Manual refresh still means "probe now" but transient refresh failures preserve the last known-good snapshot. JWT decode (`_decode_jwt_claims_unverified`) is used only for token-shape classification (Codex OAuth JWT vs raw OpenAI API key), explicitly NOT for authorization — documented in the function docstring. Per-row plan labels only shown when verified account-limit data is available. Concurrent probing capped at `min(_CODEX_POOL_MAX_WORKERS=6, len(probe_items))` so page render time stays bounded on large pools. Transient `None` probe results are NOT cached (only known unavailable/exhausted states are cached); 32-test regression suite covering pool snapshot, concurrent probe, JWT detection, cache invalidation, transient-vs-known cache distinction, and i18n parity across all currently-supported locales. Scoped to OpenAI Codex (the only provider with the credential-pool/account-limit path needed to surface this accurately).
|
||||
|
||||
BIN
docs/pr-media/2246/after-question-highlight.png
Normal file
BIN
docs/pr-media/2246/after-question-highlight.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
BIN
docs/pr-media/2246/after-question-jump-button.png
Normal file
BIN
docs/pr-media/2246/after-question-jump-button.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
BIN
docs/pr-media/2246/before-no-question-jump.png
Normal file
BIN
docs/pr-media/2246/before-no-question-jump.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
@@ -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',
|
||||
@@ -1333,6 +1335,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',
|
||||
@@ -2506,6 +2510,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: 'キューに入れたメッセージをキャンセル',
|
||||
@@ -3664,6 +3670,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: 'Отменить сообщение',
|
||||
@@ -4780,6 +4788,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',
|
||||
@@ -5892,6 +5902,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',
|
||||
@@ -7008,6 +7020,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: '取消排队消息',
|
||||
@@ -8112,6 +8126,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`,
|
||||
@@ -8566,6 +8582,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',
|
||||
@@ -9254,6 +9272,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',
|
||||
@@ -10315,6 +10335,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',
|
||||
@@ -11495,6 +11517,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)',
|
||||
|
||||
@@ -3110,6 +3110,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;
|
||||
|
||||
52
static/ui.js
52
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 `<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
tests/test_issue2246_question_jump.py
Normal file
42
tests/test_issue2246_question_jump.py
Normal 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
|
||||
Reference in New Issue
Block a user