mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-24 18:50:15 +00:00
Prefer worktree retention responses in session UI
This commit is contained in:
@@ -8,6 +8,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Session archive/delete success copy now prefers the backend `worktree_retained` response over cached session snapshots, so stale sidebar state cannot show worktree-retention reassurance after the server says no worktree was retained (refs #2111).
|
||||
|
||||
- **PR #2107** (self-built, closes #2083) — Title-generation budget-doubling retry loop on reasoning-only model responses. Reporter @darkopetrovic on LM Studio with Qwen3.6-35B-A3B (and the broader class: DeepSeek-R1, Kimi-K2, other Qwen3-thinking variants) saw GPU never going idle after each prompt — the chat turn finished cleanly but the auto-title generation request burned its 500-token budget on hidden `reasoning_content`, emitted `content=""` with `finish_reason=length`, got classified as `llm_length`, retried at 1024 tokens, returned the same shape, then iterated through `_title_prompts()`'s two prompts for ~3000 reasoning tokens per new chat. The agent-side `is_lmstudio` classifier in `run_agent.py:9468` misses `custom:` providers pointing at LM Studio, so the `reasoning_effort: "none"` adapter never fires for that route. WebUI-side belt-and-braces fix: (1) `_extract_title_response()` reorders the empty-response classification to check `reasoning_content` first regardless of `finish_reason` — reasoning presence is the diagnostic signal, not finish_reason; (2) `_title_retry_status()` drops `llm_empty_reasoning{,_aux}` from the retry set (length-without-reasoning still retries — legitimate budget-truncation case); (3) new `_title_should_skip_remaining_attempts()` short-circuits the prompt-iteration loop, both aux and agent routes break to `_fallback_title_from_exchange` for a local-summary title. Net: 4 calls → 1 call per chat. `tests/test_title_aux_routing.py` inverts the old reasoning-retry assertions and adds two new tests for the legitimate length-without-reasoning retry path. nesquena APPROVED with 200-line end-to-end trace + behavioral harness confirming the 4→1 call reduction.
|
||||
|
||||
- **PR #2064** by @franksong2702 — Worktree session archive/delete confirm copy now reassures users that the underlying worktree directory remains on disk (refs #2057). Pre-fix the confirm dialogs said only "Delete this conversation?" / "Archive this conversation?" without clarifying that worktree-backed conversations preserve the worktree files even when the conversation row is removed — users were reasonably afraid of losing local work. Adds an explicit `worktree_retained` boolean on the `/api/session` payload that the frontend reads to surface "The worktree at /path will remain on disk." (single) and "N worktree-backed conversation(s) will keep their worktree directories on disk." (bulk) variants in both archive and delete dialogs. 81-line i18n update across all 9 locales (en/it/ja/ru/es/de/zh/pt/ko) with an English-bundle locale-leak fix caught during screenshot capture (several worktree strings had landed under Russian in error). Regression coverage in `tests/test_issue2057_worktree_lifecycle.py` + `tests/test_issue2057_worktree_ui_static.py`. UX-gate cleared with 5 viewports (4×1280px desktop covering single + bulk archive/delete confirms, 1×390px mobile of single-delete confirming dialog fits without overflow).
|
||||
|
||||
+31
-10
@@ -1290,11 +1290,20 @@ function _worktreeSessionCount(ids){
|
||||
return count+(session&&session.worktree_path?1:0);
|
||||
},0);
|
||||
}
|
||||
function _sessionResponseRetainsWorktree(response, session){
|
||||
if(response&&typeof response.worktree_retained==='boolean') return response.worktree_retained;
|
||||
return !!(session&&session.worktree_path);
|
||||
}
|
||||
function _worktreeResponseCount(results){
|
||||
return (results||[]).reduce((count,result)=>{
|
||||
return count+(_sessionResponseRetainsWorktree(result&&result.response,result&&result.session)?1:0);
|
||||
},0);
|
||||
}
|
||||
function _sessionArchiveDescription(session){
|
||||
return session&&session.worktree_path?t('session_archive_worktree_desc'):t('session_archive_desc');
|
||||
}
|
||||
function _sessionArchiveToast(session){
|
||||
return session&&session.worktree_path?t('session_archived_worktree'):t('session_archived');
|
||||
function _sessionArchiveToast(response, session){
|
||||
return _sessionResponseRetainsWorktree(response,session)?t('session_archived_worktree'):t('session_archived');
|
||||
}
|
||||
function _sessionDeleteDescription(session){
|
||||
return session&&session.worktree_path?t('session_delete_worktree_desc'):t('session_delete_desc');
|
||||
@@ -1398,14 +1407,20 @@ function _renderBatchActionBar(){
|
||||
archiveBtn.onclick=async()=>{
|
||||
const ids=[..._selectedSessions];
|
||||
const wtCount=_worktreeSessionCount(ids);
|
||||
const sessionsById=new Map(ids.map(sid=>[sid,_sessionSnapshotById(sid)]));
|
||||
const ok=await showConfirmDialog({
|
||||
message:wtCount?t('session_batch_archive_worktree_confirm',ids.length,wtCount):t('session_batch_archive_confirm',ids.length),
|
||||
confirmLabel:t('session_batch_archive'),
|
||||
danger:true
|
||||
});
|
||||
if(!ok)return;
|
||||
try{await Promise.all(ids.map(sid=>api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:sid,archived:true})})));
|
||||
showToast(wtCount?t('session_archived_worktree'):t('session_archived'));exitSessionSelectMode();await renderSessionList();
|
||||
try{
|
||||
const results=await Promise.all(ids.map(async sid=>{
|
||||
const response=await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:sid,archived:true})});
|
||||
return {response,session:sessionsById.get(sid)||null};
|
||||
}));
|
||||
const retainedCount=_worktreeResponseCount(results);
|
||||
showToast(retainedCount?t('session_archived_worktree'):t('session_archived'));exitSessionSelectMode();await renderSessionList();
|
||||
}catch(e){showToast('Archive failed: '+(e.message||e));}
|
||||
};bar.appendChild(archiveBtn);
|
||||
// Move
|
||||
@@ -1418,6 +1433,7 @@ function _renderBatchActionBar(){
|
||||
deleteBtn.onclick=async()=>{
|
||||
const ids=[..._selectedSessions];
|
||||
const wtCount=_worktreeSessionCount(ids);
|
||||
const sessionsById=new Map(ids.map(sid=>[sid,_sessionSnapshotById(sid)]));
|
||||
const ok=await showConfirmDialog({
|
||||
message:wtCount?t('session_batch_delete_worktree_confirm',ids.length,wtCount):t('session_batch_delete_confirm',ids.length),
|
||||
confirmLabel:t('delete_title'),
|
||||
@@ -1425,7 +1441,11 @@ function _renderBatchActionBar(){
|
||||
});
|
||||
if(!ok)return;
|
||||
try{
|
||||
await Promise.all(ids.map(sid=>api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})})));
|
||||
const results=await Promise.all(ids.map(async sid=>{
|
||||
const response=await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})});
|
||||
return {response,session:sessionsById.get(sid)||null};
|
||||
}));
|
||||
const retainedCount=_worktreeResponseCount(results);
|
||||
ids.forEach(_clearHandoffStorageForSession);
|
||||
if(S.session&&ids.includes(S.session.session_id)){
|
||||
S.session=null;S.messages=[];S.entries=[];localStorage.removeItem('hermes-webui-session');
|
||||
@@ -1433,7 +1453,7 @@ function _renderBatchActionBar(){
|
||||
if(remaining.sessions&&remaining.sessions.length){await loadSession(remaining.sessions[0].session_id);}
|
||||
else{$('msgInner').innerHTML='';$('emptyState').style.display='';}
|
||||
}
|
||||
showToast((wtCount?t('session_deleted_worktree'):t('session_delete'))+' ('+ids.length+')');exitSessionSelectMode();await renderSessionList();
|
||||
showToast((retainedCount?t('session_deleted_worktree'):t('session_delete'))+' ('+ids.length+')');exitSessionSelectMode();await renderSessionList();
|
||||
}catch(e){showToast('Delete failed: '+(e.message||e));}
|
||||
};bar.appendChild(deleteBtn);
|
||||
}
|
||||
@@ -1606,11 +1626,11 @@ function _openSessionActionMenu(session, anchorEl){
|
||||
async()=>{
|
||||
closeSessionActionMenu();
|
||||
try{
|
||||
await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived:!session.archived})});
|
||||
const response=await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived:!session.archived})});
|
||||
session.archived=!session.archived;
|
||||
if(S.session&&S.session.session_id===session.session_id) S.session.archived=session.archived;
|
||||
await renderSessionList();
|
||||
showToast(session.archived?_sessionArchiveToast(session):t('session_restored'));
|
||||
showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored'));
|
||||
}catch(err){showToast(t('session_archive_failed')+err.message);}
|
||||
}
|
||||
));
|
||||
@@ -3051,8 +3071,9 @@ async function deleteSession(sid){
|
||||
danger:true
|
||||
});
|
||||
if(!ok)return;
|
||||
let response=null;
|
||||
try{
|
||||
await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})});
|
||||
response=await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})});
|
||||
_clearHandoffStorageForSession(sid);
|
||||
}catch(e){setStatus(`Delete failed: ${e.message}`);return;}
|
||||
if(S.session&&S.session.session_id===sid){
|
||||
@@ -3072,7 +3093,7 @@ async function deleteSession(sid){
|
||||
if(typeof syncAppTitlebar==='function') syncAppTitlebar();
|
||||
}
|
||||
}
|
||||
showToast(session&&session.worktree_path?t('session_deleted_worktree'):t('session_deleted'));
|
||||
showToast(_sessionResponseRetainsWorktree(response,session)?t('session_deleted_worktree'):t('session_deleted'));
|
||||
await renderSessionList();
|
||||
}
|
||||
|
||||
|
||||
@@ -22,11 +22,13 @@ class TestSidebarCancelAction:
|
||||
def test_running_sidebar_sessions_get_stop_action(self):
|
||||
"""Running sessions need a context-menu cancel action even when not active pane."""
|
||||
# Window bumped from 3200 → 4400 in #1764 to accommodate the new
|
||||
# Rename action item that lands at the top of _openSessionActionMenu.
|
||||
# Rename action item, then to 5200 in #2111 for response-aware archive
|
||||
# toast handling inside _openSessionActionMenu before the stop/delete
|
||||
# actions.
|
||||
# The `session.active_stream_id` / cancelSessionStream / delete checks
|
||||
# are positional further down in the function, so growing the prefix
|
||||
# required growing this read window.
|
||||
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 4400)
|
||||
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 5200)
|
||||
assert "session.active_stream_id" in body, (
|
||||
"sidebar action menu must detect per-session active_stream_id instead of S.activeStreamId"
|
||||
)
|
||||
@@ -72,8 +74,9 @@ class TestSidebarCancelAction:
|
||||
|
||||
def test_cli_sessions_hide_duplicate_and_delete_in_action_menu(self):
|
||||
"""Session action menu should hide duplicate/delete for CLI-origin sessions."""
|
||||
# Window bumped 3600 → 4800 in #1764 (Rename action prepended).
|
||||
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 4800)
|
||||
# Window bumped 3600 → 4800 in #1764 (Rename action prepended), then
|
||||
# to 5200 in #2111 for response-aware archive toast handling.
|
||||
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 5200)
|
||||
assert "const isCliSession = _isCliSession(session);" in body
|
||||
assert "const isExternalSession = isMessagingSession || isCliSession;" in body
|
||||
assert "if(!isExternalSession)" in body
|
||||
|
||||
@@ -24,6 +24,7 @@ def test_batch_archive_delete_confirmations_count_worktree_sessions():
|
||||
src = read("static/sessions.js")
|
||||
i18n = read("static/i18n.js")
|
||||
assert "function _worktreeSessionCount(ids)" in src
|
||||
assert "function _worktreeResponseCount(results)" in src
|
||||
assert "session_batch_delete_worktree_confirm" in src
|
||||
assert "session_batch_archive_worktree_confirm" in src
|
||||
assert "session_batch_delete_worktree_confirm" in i18n
|
||||
@@ -43,6 +44,23 @@ def test_archive_and_delete_action_descriptions_are_worktree_specific():
|
||||
assert "session_archived_worktree: 'Session archived. Worktree remains on disk.'" in i18n
|
||||
|
||||
|
||||
def test_archive_delete_success_copy_prefers_response_worktree_retained():
|
||||
src = read("static/sessions.js")
|
||||
assert "function _sessionResponseRetainsWorktree(response, session)" in src
|
||||
assert "typeof response.worktree_retained==='boolean'" in src
|
||||
assert "return response.worktree_retained;" in src
|
||||
assert "return !!(session&&session.worktree_path);" in src
|
||||
assert src.index("return response.worktree_retained;") < src.index(
|
||||
"return !!(session&&session.worktree_path);"
|
||||
)
|
||||
assert "function _sessionArchiveToast(response, session)" in src
|
||||
assert "session.archived?_sessionArchiveToast(response,session):t('session_restored')" in src
|
||||
assert "_sessionResponseRetainsWorktree(response,session)?t('session_deleted_worktree')" in src
|
||||
assert "const retainedCount=_worktreeResponseCount(results)" in src
|
||||
assert "showToast(retainedCount?t('session_archived_worktree'):t('session_archived'))" in src
|
||||
assert "showToast((retainedCount?t('session_deleted_worktree'):t('session_delete'))" in src
|
||||
|
||||
|
||||
def test_worktree_archive_delete_api_responses_are_explicit():
|
||||
src = read("api/routes.py")
|
||||
assert "def _worktree_retained_payload(session)" in src
|
||||
|
||||
Reference in New Issue
Block a user