Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@

- **New RFC: Stable Assistant Turn Anchors for Live-to-Final rendering.** Defines a frontend presentation/reconciliation model for anchoring one assistant turn across live streaming, settlement, replay/reload/recovery, Compact Worklog, Transparent Stream, terminal states, artifacts, and side effects. (#3926)

## [v0.51.352] — 2026-06-10 — Release LP (medium round: jump-to-response, STATE_DIR warning, J/K nav)

### Added

- **J/K keyboard shortcuts navigate the session list.** Pressing `j` selects the next session and `k` the previous one (vim-style), guarded so they never fire while typing in the composer or any input/textarea/contenteditable, and modifier-key combos are ignored. (#3941, closes #3845)

### Fixed

- **The per-turn jump button now scrolls to the start of the response, not the user's question.** The ↑ jump affordance on an assistant turn now scrolls to the assistant segment (the start of the response) instead of the user message above it. It targets the first *visible* segment — a folded-Worklog turn whose first segment is hidden no longer suppresses the fallback — and falls back to the question row when no visible response segment is found. Label updated to "to response" across all locales. (#3934, closes #3852)
- **Startup now warns when the session store is empty but a sibling state directory has sessions.** When `SESSION_DIR` has no session files and the index is empty/absent, `print_startup_config()` scans sibling state directories and, if it finds one with session data, prints a diagnostic pointing at it and the `HERMES_WEBUI_STATE_DIR` to set — so a user who switched launch methods (bootstrap.py / ctl.sh / systemd) and sees an empty sidebar knows their sessions aren't lost. Fully fail-safe (warning only, no behavior change). (#3939, closes #3915)

## [v0.51.351] — 2026-06-10 — Release LO (Phase 0 brick batch: data-loss + mobile stream reattach)

### Fixed
Expand Down
64 changes: 64 additions & 0 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,65 @@ def _discover_default_workspace() -> Path:


# ── Startup diagnostics ───────────────────────────────────────────────────────
def _warn_state_dir_divergence(warn_prefix: str) -> None:
"""Check if SESSION_DIR is empty but a sibling state directory has session data.

If the session store looks empty (no *.json files besides _index.json in SESSION_DIR,
or SESSION_INDEX_FILE is absent/empty/contains only {}|[]|null), scan STATE_DIR.parent
for sibling directories with a sessions/ child that has .json files.

Prints a diagnostic warning if a divergence is detected, helping users identify when
they may have switched launch methods and the HERMES_WEBUI_STATE_DIR env var differs.
"""
try:
# Check if session store is empty
session_dir_empty = False

# Check for .json files in SESSION_DIR (excluding _index.json)
if SESSION_DIR.exists():
json_files = [f for f in SESSION_DIR.glob("*.json") if f.name != "_index.json"]
session_dir_empty = len(json_files) == 0
else:
session_dir_empty = True

# Check if SESSION_INDEX_FILE is absent, empty, or contains only {}|[]|null
index_file_empty = True
if SESSION_INDEX_FILE.exists():
try:
with open(SESSION_INDEX_FILE, "r") as f:
content = f.read().strip()
if content and content not in ("{}", "[]", "null"):
index_file_empty = False
except Exception:
pass

# If session store looks empty, scan for siblings with sessions
if session_dir_empty and index_file_empty:
state_parent = STATE_DIR.parent
if state_parent.exists():
for sibling in state_parent.iterdir():
if not sibling.is_dir() or sibling == STATE_DIR:
continue
sibling_sessions = sibling / "sessions"
if sibling_sessions.exists():
json_files = [f for f in sibling_sessions.glob("*.json") if f.name != "_index.json"]
if json_files:
# Found a sibling with session data
print(
f"{warn_prefix} STATE_DIR is empty but a sibling state directory has session data.\n"
f" Current : {STATE_DIR}\n"
f" Sibling : {sibling}\n"
f" If you switched launch methods (bootstrap.py / ctl.sh / systemd),\n"
f" the active HERMES_WEBUI_STATE_DIR env var may differ from the\n"
f" previous run. Set it explicitly to restore access:\n"
f" export HERMES_WEBUI_STATE_DIR={sibling}",
flush=True,
)
return
except Exception:
pass


def print_startup_config() -> None:
"""Print detected configuration at startup so the user can verify what was found."""
ok = "\033[32m[ok]\033[0m"
Expand All @@ -595,6 +654,11 @@ def print_startup_config() -> None:
]
print("\n".join(lines), flush=True)

try:
_warn_state_dir_divergence(warn)
except Exception:
pass

if not _HERMES_FOUND:
print(
f"{err} Could not find the Hermes agent directory.\n"
Expand Down
52 changes: 26 additions & 26 deletions static/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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: 'キューに入れたメッセージをキャンセル',
Expand Down Expand Up @@ -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: 'Отменить сообщение',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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: '取消排队消息',
Expand Down Expand Up @@ -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: '取消排隊訊息',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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)',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
19 changes: 19 additions & 0 deletions static/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -6169,3 +6169,22 @@ async function _confirmDeleteProject(proj){
document.addEventListener('keydown',(e)=>{
if(e.key==='Escape'&&_sessionSelectMode) exitSessionSelectMode();
});

// Keyboard session navigation — J/K bindings
function navigateSession(dir){
const rows=[...document.querySelectorAll('.session-item[data-sid]')];
const sids=rows.map(r=>r.dataset.sid);
const cur=S.session&&S.session.session_id;
const i=sids.indexOf(cur);
if(i<0||!sids.length)return;
const next=sids[Math.min(Math.max(i+dir,0),sids.length-1)];
if(next&&next!==cur) loadSession(next);
}

document.addEventListener('keydown',(e)=>{
if(e.key!=='j'&&e.key!=='k') return;
if(e.ctrlKey||e.metaKey||e.altKey) return;
if(typeof _isInteractiveSwipeTarget==='function'&&_isInteractiveSwipeTarget(e.target)) return;
Comment on lines +6184 to +6187

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Adding if(e.repeat) return; prevents the handler from firing on auto-repeated keydown events, so holding 'j' or 'k' won't rapidly cycle through every session in the list.

Suggested change
document.addEventListener('keydown',(e)=>{
if(e.key!=='j'&&e.key!=='k') return;
if(e.ctrlKey||e.metaKey||e.altKey) return;
if(typeof _isInteractiveSwipeTarget==='function'&&_isInteractiveSwipeTarget(e.target)) return;
document.addEventListener('keydown',(e)=>{
if(e.key!=='j'&&e.key!=='k') return;
if(e.repeat) return;
if(e.ctrlKey||e.metaKey||e.altKey) return;
if(typeof _isInteractiveSwipeTarget==='function'&&_isInteractiveSwipeTarget(e.target)) return;

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

e.preventDefault();
navigateSession(e.key==='j'?1:-1);
});
32 changes: 26 additions & 6 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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){
Expand All @@ -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'});
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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>`;

Expand Down
Loading
Loading