Release v0.51.241 — Release HI (stage-q13) (#3498)
## 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 <starGazerK@users.noreply.github.com>
This commit is contained in:
1 parent
1d7a139c26
commit
e7930ad9a5
4 files changed
+185
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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=()=>{
|
||||
|
||||
+64
-3
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
Reference in new issue
Block a user