From e7930ad9a5a394bf3ecf58e7149609acac874371 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 3 Jun 2026 15:24:31 -0700 Subject: [PATCH] =?UTF-8?q?Release=20v0.51.241=20=E2=80=94=20Release=20HI?= =?UTF-8?q?=20(stage-q13)=20(#3498)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Release v0.51.241 — Release HI (stage-q13) UX-flow bug-fix — approved via Telegram. ### Fixed | PR | Author | Fix | |----|--------|-----| | #3471 (#3333) | @starGazerK | **New Chat keeps your unsent draft after peeking at history.** Start a New Chat draft → open a previous conversation → click New Chat: the draft is no longer lost. Empty New-Chat sessions are hidden from the sidebar, so there was no way back to the session holding the draft — New Chat just created another fresh empty session. The entrypoint now remembers the candidate empty draft session (one `localStorage` pointer) and, before creating a fresh session, re-validates it via `/api/session`, routing back only if it is still a safe empty draft (zero messages, no active stream, no pending message, not worktree-backed, matching profile, non-empty server-side `composer_draft`). | ### Why it's safe for existing installs - When there is no remembered draft, it's a **pure no-op fall-through** to the existing `newSession()` path — no behavior change. - Preserves the "zero-message sessions stay hidden from the sidebar" contract. - Conservative multi-guard validation; the pointer is cleared on draft-clear (after send) so an emptied draft never traps you on New Chat. ### Verified live (end-to-end on a test server) - **Positive**: typed a draft → visited a 2-message history session → clicked New Chat → landed back on the draft session with the text restored. - **Negative**: emptied the draft → New Chat created a fresh session (no accidental trap). ### Absorbed on review (Codex CORE MUST-FIX) The PR added `await _saveComposerDraftNow(...)` before the session-switch, which opened a rapid-switch race: clicking session B then quickly C could let B's stale continuation blank C's freshly-loaded state. Added `if (_loadingSessionId !== sid) return;` immediately after the awaited save and before the destructive state-clear (mirrors the existing #1060 stale-guard) + a regression test pinning the guard's position. ### Gate - Full pytest suite: **7510 passed, 9 skipped, 3 xpassed, 0 failed** - ESLint runtime gate: CLEAN · browser-smoke: CLEAN - Codex (regression): SHIP-ONLY-WITH-FIXES (rapid-switch race) → fixed → re-reviewed **SAFE TO SHIP** - `tests/test_issue_new_chat_draft_restore.py` (7 assertions incl. the race-guard; live-verified behavior) UX-flow change, no visual/layout delta — approved via Telegram. Co-authored-by: starGazerK --- CHANGELOG.md | 5 + static/boot.js | 4 + static/sessions.js | 67 +++++++++++- tests/test_issue_new_chat_draft_restore.py | 112 +++++++++++++++++++++ 4 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 tests/test_issue_new_chat_draft_restore.py diff --git a/CHANGELOG.md b/CHANGELOG.md index deade635..f55c2c2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## [Unreleased] +## [v0.51.241] — 2026-06-03 — Release HI (stage-q13 — New Chat returns to your unsent draft after visiting history) + +### Fixed +- Starting a **New Chat** draft, peeking at a previous conversation, then clicking **New Chat** again no longer loses your unsent prompt. Zero-message New Chat sessions are intentionally hidden from the sidebar, so after you navigated away there was no way back to the empty session that held your draft — New Chat just created another fresh empty session and the draft was stranded. The New Chat entrypoint now remembers the candidate empty draft session (a single `localStorage` pointer) and, before creating a fresh session, re-validates it through `/api/session` and routes back only if it is still a safe empty draft (zero messages, no active stream, no pending message, not worktree-backed, matching profile, and a non-empty server-side `composer_draft`). The composer draft is also flushed to the server before a session switch so typing and immediately navigating away can't drop it. Clearing the draft (e.g. after sending) clears the pointer, so an emptied draft never traps you on New Chat. (#3333, #3471, @starGazerK) + ## [v0.51.240] — 2026-06-03 — Release HH (stage-q12 — mobile swipe-up stops streaming auto-scroll) ### Fixed diff --git a/static/boot.js b/static/boot.js index 1626500d..f7fcfb14 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1069,6 +1069,10 @@ $('btnNewChat').onclick=async()=>{ && !S.session.pending_user_message){ $('msg').focus();closeMobileSidebar();return; } + if(typeof _restoreRememberedNewChatDraftSession==='function' + && await _restoreRememberedNewChatDraftSession()){ + await renderSessionList();closeMobileSidebar();$('msg').focus();return; + } await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus(); }; $('btnDownload').onclick=()=>{ diff --git a/static/sessions.js b/static/sessions.js index 41b93b04..ae8b2740 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -31,6 +31,57 @@ let _pendingCarryForwardSnapshot = null; // Debounced save — prevents hammering the server on every keystroke. let _draftSaveTimer = null; const _DRAFT_SAVE_DELAY_MS = 400; +const NEW_CHAT_DRAFT_SESSION_KEY = 'hermes-new-chat-draft-session'; + +function _isRestorableNewChatDraftSession(session, requireDraft=false) { + if (!session || !session.session_id) return false; + const messageCount = Number(session.message_count || 0); + if (messageCount !== 0) return false; + if (session.active_stream_id || session.pending_user_message || session.worktree_path) return false; + const title = session.title || 'Untitled'; + if (title !== 'Untitled' && title !== 'New Chat') return false; + const activeProfile = S.activeProfile || 'default'; + const sessionProfile = session.profile || 'default'; + if (sessionProfile !== activeProfile) return false; + if (!requireDraft) return true; + const draft = session.composer_draft || {}; + const text = (typeof draft.text === 'string') ? draft.text : ''; + const files = Array.isArray(draft.files) ? draft.files : []; + return !!(text || files.length); +} + +function _rememberNewChatDraftSession(session) { + if (!_isRestorableNewChatDraftSession(session)) return; + try { localStorage.setItem(NEW_CHAT_DRAFT_SESSION_KEY, session.session_id); } catch (_) {} +} + +function _clearRememberedNewChatDraftSession(sid) { + if (!sid) return; + try { + if (localStorage.getItem(NEW_CHAT_DRAFT_SESSION_KEY) === sid) { + localStorage.removeItem(NEW_CHAT_DRAFT_SESSION_KEY); + } + } catch (_) {} +} + +async function _restoreRememberedNewChatDraftSession() { + let sid = ''; + try { sid = localStorage.getItem(NEW_CHAT_DRAFT_SESSION_KEY) || ''; } catch (_) { sid = ''; } + if (!sid || (S.session && S.session.session_id === sid)) return false; + try { + const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`); + const session = data && data.session; + if (!_isRestorableNewChatDraftSession(session, true)) { + _clearRememberedNewChatDraftSession(sid); + return false; + } + await loadSession(sid, {skipLineageResolve:true}); + return !!(S.session && S.session.session_id === sid); + } catch (_) { + _clearRememberedNewChatDraftSession(sid); + return false; + } +} function _saveComposerDraft(sid, text, files) { if (!sid) return; @@ -43,11 +94,11 @@ function _saveComposerDraft(sid, text, files) { }, _DRAFT_SAVE_DELAY_MS); } -// Fire-and-forget immediate save (used before session switches). +// Immediate save used before session switches. function _saveComposerDraftNow(sid, text, files) { if (!sid) return; clearTimeout(_draftSaveTimer); - api('/api/session/draft', { + return api('/api/session/draft', { method: 'POST', body: JSON.stringify({ session_id: sid, text: text || '', files: files || [] }), }).catch(() => {}); @@ -97,6 +148,7 @@ function _restoreComposerDraft(draft, targetSid, opts={}) { function _clearComposerDraft(sid) { if (!sid) return; clearTimeout(_draftSaveTimer); + _clearRememberedNewChatDraftSession(sid); api('/api/session/draft', { method: 'POST', body: JSON.stringify({ session_id: sid, text: '' }), @@ -560,6 +612,7 @@ async function newSession(flash, options={}){ S.session=data.session;S.messages=data.session.messages||[]; if(_sessionSourceFilter==='cli') _sessionSourceFilter='webui'; S.lastUsage={...(data.session.last_usage||{})}; + if(!(options&&options.worktree)) _rememberNewChatDraftSession(S.session); if(flash)S.session._flash=true; try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} _setActiveSessionUrl(S.session.session_id); @@ -656,7 +709,15 @@ async function loadSession(sid){ // restored when the user switches back (#1060). Save to server now so the // draft survives page refresh and syncs across clients. if (currentSid && currentSid !== sid) { - _saveComposerDraftNow(currentSid, ($('msg') || {}).value || '', S.pendingFiles ? [...S.pendingFiles] : []); + await _saveComposerDraftNow(currentSid, ($('msg') || {}).value || '', S.pendingFiles ? [...S.pendingFiles] : []); + // The awaited draft save above yields the event loop. If another + // loadSession() started for a different session while we were waiting + // (rapid switch B→C), _loadingSessionId now points at that newer load — + // bail out before the destructive state-clearing block below so this stale + // continuation can't wipe S.messages / write the loading placeholder / + // close streams for the session the user actually landed on (#1060 guard, + // extended to cover the new pre-switch await). + if (_loadingSessionId !== sid) return; } if (currentSid !== sid || forceReload) { // #3306: When force-reloading the currently-active session (e.g. external diff --git a/tests/test_issue_new_chat_draft_restore.py b/tests/test_issue_new_chat_draft_restore.py new file mode 100644 index 00000000..f44ba7d1 --- /dev/null +++ b/tests/test_issue_new_chat_draft_restore.py @@ -0,0 +1,112 @@ +"""Regression coverage for New Chat draft-session restoration. + +The bug: New Chat -> type draft -> open history -> New Chat created a fresh +empty session instead of returning to the empty session that owns the draft. +""" +from pathlib import Path + + +ROOT = Path(__file__).parents[1] +SESSIONS_JS = ROOT.joinpath("static", "sessions.js").read_text(encoding="utf-8") +BOOT_JS = ROOT.joinpath("static", "boot.js").read_text(encoding="utf-8") + + +def _btn_new_chat_handler() -> str: + start = BOOT_JS.find("$('btnNewChat').onclick=async()=>{") + end = BOOT_JS.find("$('btnDownload').onclick", start) + assert start != -1 and end != -1, "btnNewChat handler block not found" + return BOOT_JS[start:end] + + +def test_new_session_remembers_regular_empty_session_id(): + start = SESSIONS_JS.find("async function newSession(") + end = SESSIONS_JS.find("async function loadSession(", start) + assert start != -1 and end != -1, "newSession block not found" + body = SESSIONS_JS[start:end] + assign_idx = body.find("S.session=data.session") + remember_idx = body.find("_rememberNewChatDraftSession(S.session)") + assert assign_idx != -1, "newSession must assign S.session from the POST response" + assert remember_idx > assign_idx, "newSession must remember the created empty session id" + assert "if(!(options&&options.worktree)) _rememberNewChatDraftSession(S.session);" in body, ( + "worktree-backed new sessions must not become New Chat draft candidates" + ) + + +def test_new_chat_button_restores_remembered_draft_before_creating_session(): + body = _btn_new_chat_handler() + restore_idx = body.find("_restoreRememberedNewChatDraftSession") + new_idx = body.find("await newSession()") + assert restore_idx != -1, "New Chat button must try the remembered draft session first" + assert new_idx != -1, "New Chat button must still fall back to creating a session" + assert restore_idx < new_idx, "draft-session restore must happen before newSession fallback" + assert "return;" in body[restore_idx:new_idx], ( + "successful draft-session restore must return instead of also creating a new session" + ) + + +def test_restore_helper_validates_candidate_with_session_metadata(): + assert "const NEW_CHAT_DRAFT_SESSION_KEY = 'hermes-new-chat-draft-session';" in SESSIONS_JS + assert "async function _restoreRememberedNewChatDraftSession()" in SESSIONS_JS + assert "messages=0&resolve_model=0" in SESSIONS_JS, ( + "helper should validate the hidden zero-message candidate through /api/session metadata" + ) + assert "_isRestorableNewChatDraftSession(session, true)" in SESSIONS_JS, ( + "candidate must have a non-empty server-side composer_draft before restore" + ) + assert "await loadSession(sid, {skipLineageResolve:true});" in SESSIONS_JS, ( + "helper should load the exact hidden empty draft session" + ) + + +def test_session_switch_awaits_immediate_draft_flush_before_loading_target(): + assert "return api('/api/session/draft'" in SESSIONS_JS, ( + "_saveComposerDraftNow should return its POST promise so switch-away can await it" + ) + assert "await _saveComposerDraftNow(currentSid" in SESSIONS_JS, ( + "loadSession must flush the current draft before fetching the next session" + ) + + +def test_pre_switch_draft_flush_rechecks_stale_loading_guard(): + """The awaited draft-save in loadSession yields the event loop. On a rapid + session switch (B then quickly C) the stale B continuation must bail out + before the destructive state-clearing block, or it would wipe the + freshly-loaded C state. The guard is `if (_loadingSessionId !== sid) return;` + placed AFTER the awaited save and BEFORE the `S.messages = []` clear + (Codex pre-release CORE catch, #3471).""" + start = SESSIONS_JS.find("async function loadSession(") + assert start != -1, "loadSession not found" + body = SESSIONS_JS[start:start + 4000] + await_idx = body.find("await _saveComposerDraftNow(currentSid") + guard_idx = body.find("if (_loadingSessionId !== sid) return;", await_idx) + clear_idx = body.find("S.messages = [];", await_idx) + assert await_idx != -1, "pre-switch awaited draft save not found" + assert guard_idx != -1, "stale-loading guard missing after the awaited draft save" + assert clear_idx != -1, "destructive S.messages clear not found" + assert await_idx < guard_idx < clear_idx, ( + "the _loadingSessionId stale-guard must sit between the awaited draft " + "save and the destructive state clear so a rapid switch can't blank the " + "newer session" + ) + + +def test_restorable_candidate_rejects_in_flight_worktree_and_cross_profile_sessions(): + start = SESSIONS_JS.find("function _isRestorableNewChatDraftSession(") + end = SESSIONS_JS.find("function _rememberNewChatDraftSession", start) + assert start != -1 and end != -1, "restorable candidate helper not found" + body = SESSIONS_JS[start:end] + assert "messageCount !== 0" in body + assert "session.active_stream_id || session.pending_user_message || session.worktree_path" in body + assert "sessionProfile !== activeProfile" in body + assert "session.composer_draft || {}" in body + assert "text || files.length" in body + + +def test_clear_composer_draft_forgets_same_new_chat_candidate(): + start = SESSIONS_JS.find("function _clearComposerDraft(") + end = SESSIONS_JS.find("const SESSION_VIEWED_COUNTS_KEY", start) + assert start != -1 and end != -1, "_clearComposerDraft block not found" + body = SESSIONS_JS[start:end] + assert "_clearRememberedNewChatDraftSession(sid);" in body, ( + "sending a draft must stop New Chat from restoring that now-cleared candidate" + )