diff --git a/CHANGELOG.md b/CHANGELOG.md index c413c123..ea060626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/static/sessions.js b/static/sessions.js index 35724911..89d7e792 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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(); } diff --git a/tests/test_1466_sidebar_cancel_clarify.py b/tests/test_1466_sidebar_cancel_clarify.py index 890c745b..cc87bbf2 100644 --- a/tests/test_1466_sidebar_cancel_clarify.py +++ b/tests/test_1466_sidebar_cancel_clarify.py @@ -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 diff --git a/tests/test_issue2057_worktree_ui_static.py b/tests/test_issue2057_worktree_ui_static.py index 9e1653d4..b9233280 100644 --- a/tests/test_issue2057_worktree_ui_static.py +++ b/tests/test_issue2057_worktree_ui_static.py @@ -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