fix(ui): persist session queue to sessionStorage across page refresh (#768)
Queued follow-up messages now survive page refresh. Persisted atomically in queueSessionMessage/shiftQueuedSessionMessage. On reload: if agent still active, queue is silently hydrated (done handler drains it); if idle, first entry is restored as a composer draft with a toast. Stale entries discarded. Fixes #660
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Hermes Web UI -- Changelog
|
# Hermes Web UI -- Changelog
|
||||||
|
|
||||||
|
## [v0.50.117] — 2026-04-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Queued messages survive page refresh** — when a follow-up message is submitted while the agent is busy, the queue is now persisted to `sessionStorage`. On reload, if the agent is still running the queue is silently restored and will drain normally. If the agent has finished, the first queued message is restored into the composer as a draft with a toast notification ("Queued message restored — review and send when ready"), preventing accidental auto-send. Stale entries (created before the last assistant response) are automatically discarded. (#660)
|
||||||
|
|
||||||
## [v0.50.116] — 2026-04-20
|
## [v0.50.116] — 2026-04-20
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -83,6 +83,44 @@ async function loadSession(sid){
|
|||||||
attachLiveStream(sid, activeStreamId, data.session.pending_attachments||[], {reconnecting:true});
|
attachLiveStream(sid, activeStreamId, data.session.pending_attachments||[], {reconnecting:true});
|
||||||
}
|
}
|
||||||
}else{
|
}else{
|
||||||
|
// Restore any queued message that survived page refresh via sessionStorage.
|
||||||
|
// Only restore when the agent is idle — if active, the done handler drains it.
|
||||||
|
if(typeof queueSessionMessage==='function'){
|
||||||
|
try{
|
||||||
|
const _storedQ=sessionStorage.getItem('hermes-queue-'+sid);
|
||||||
|
if(_storedQ){
|
||||||
|
const _entries=JSON.parse(_storedQ);
|
||||||
|
if(Array.isArray(_entries)&&_entries.length){
|
||||||
|
// Timestamp guard: drop entries older than the last assistant response
|
||||||
|
// (means the agent already ran and the queue was already dispatched)
|
||||||
|
const _lastMsg=(data.session.messages||[]).slice().reverse()
|
||||||
|
.find(m=>m&&m.role==='assistant');
|
||||||
|
const _lastAsst=_lastMsg?(_lastMsg.timestamp||_lastMsg._ts||0)*1000:0;
|
||||||
|
const _fresh=_entries.filter(e=>!e._queued_at||e._queued_at>_lastAsst);
|
||||||
|
if(_fresh.length){
|
||||||
|
// Idle path: restore the first entry as a composer draft only. Do NOT
|
||||||
|
// re-enqueue into SESSION_QUEUES — if we did, send() would dispatch the
|
||||||
|
// draft directly (S.busy=false) and then setBusy(false) would drain the
|
||||||
|
// same entry from the queue, causing a duplicate send. Any follow-up
|
||||||
|
// entries (2..N) are discarded by design; the toast tells the user so.
|
||||||
|
const _first=_fresh[0];
|
||||||
|
const _msg=$&&$('msg');
|
||||||
|
if(_msg&&_first.text&&!_msg.value){
|
||||||
|
_msg.value=_first.text||'';
|
||||||
|
if(typeof autoResize==='function') autoResize();
|
||||||
|
if(typeof showToast==='function') showToast((_fresh.length>1?`${_fresh.length} queued messages restored (showing first)`:'Queued message restored')+' — review and send when ready');
|
||||||
|
}
|
||||||
|
// Clear persisted queue now that the draft is in the composer
|
||||||
|
sessionStorage.removeItem('hermes-queue-'+sid);
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem('hermes-queue-'+sid);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem('hermes-queue-'+sid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}catch(_){sessionStorage.removeItem('hermes-queue-'+sid);}
|
||||||
|
}
|
||||||
updateQueueBadge(sid);
|
updateQueueBadge(sid);
|
||||||
S.messages=data.session.messages||[];
|
S.messages=data.session.messages||[];
|
||||||
const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(data.session):null;
|
const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(data.session):null;
|
||||||
|
|||||||
13
static/ui.js
13
static/ui.js
@@ -10,14 +10,23 @@ function _getSessionQueue(sid, create=false){
|
|||||||
function queueSessionMessage(sid, payload){
|
function queueSessionMessage(sid, payload){
|
||||||
if(!sid||!payload) return 0;
|
if(!sid||!payload) return 0;
|
||||||
const q=_getSessionQueue(sid,true);
|
const q=_getSessionQueue(sid,true);
|
||||||
q.push(payload);
|
// Stamp created_at so the restore path can detect stale entries (agent already responded)
|
||||||
|
const entry={...payload, _queued_at: Date.now()};
|
||||||
|
q.push(entry);
|
||||||
|
// Persist to sessionStorage so the queue survives page refresh
|
||||||
|
try{ sessionStorage.setItem('hermes-queue-'+sid, JSON.stringify(q)); }catch(_){}
|
||||||
return q.length;
|
return q.length;
|
||||||
}
|
}
|
||||||
function shiftQueuedSessionMessage(sid){
|
function shiftQueuedSessionMessage(sid){
|
||||||
const q=_getSessionQueue(sid,false);
|
const q=_getSessionQueue(sid,false);
|
||||||
if(!q.length) return null;
|
if(!q.length) return null;
|
||||||
const next=q.shift();
|
const next=q.shift();
|
||||||
if(!q.length) delete SESSION_QUEUES[sid];
|
if(!q.length){
|
||||||
|
delete SESSION_QUEUES[sid];
|
||||||
|
try{ sessionStorage.removeItem('hermes-queue-'+sid); }catch(_){}
|
||||||
|
} else {
|
||||||
|
try{ sessionStorage.setItem('hermes-queue-'+sid, JSON.stringify(q)); }catch(_){}
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
function getQueuedSessionCount(sid){
|
function getQueuedSessionCount(sid){
|
||||||
|
|||||||
76
tests/test_issue660.py
Normal file
76
tests/test_issue660.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
Tests for #660: session queue persistence across page refresh.
|
||||||
|
|
||||||
|
The queue is stored to sessionStorage when entries are added/removed,
|
||||||
|
and restored from sessionStorage on session load when the agent is idle.
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
UI_JS = pathlib.Path(__file__).parent.parent / 'static' / 'ui.js'
|
||||||
|
SESSIONS_JS = pathlib.Path(__file__).parent.parent / 'static' / 'sessions.js'
|
||||||
|
|
||||||
|
ui_src = UI_JS.read_text(encoding='utf-8')
|
||||||
|
sess_src = SESSIONS_JS.read_text(encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueuePersistence:
|
||||||
|
"""queueSessionMessage persists to sessionStorage."""
|
||||||
|
|
||||||
|
def test_queue_writes_to_session_storage(self):
|
||||||
|
"""queueSessionMessage must write to sessionStorage after enqueueing."""
|
||||||
|
assert "sessionStorage.setItem('hermes-queue-'+sid" in ui_src
|
||||||
|
|
||||||
|
def test_queue_stamps_queued_at_timestamp(self):
|
||||||
|
"""Each queue entry must have a _queued_at timestamp for stale-entry detection."""
|
||||||
|
assert '_queued_at' in ui_src
|
||||||
|
|
||||||
|
def test_shift_removes_from_session_storage(self):
|
||||||
|
"""shiftQueuedSessionMessage must remove/update sessionStorage on dequeue."""
|
||||||
|
assert "sessionStorage.removeItem('hermes-queue-'+sid)" in ui_src
|
||||||
|
|
||||||
|
def test_shift_updates_session_storage_when_items_remain(self):
|
||||||
|
"""When queue still has items after shift, sessionStorage is updated (not removed)."""
|
||||||
|
# After shift: if queue still has items, update storage with remaining
|
||||||
|
assert "sessionStorage.setItem('hermes-queue-'+sid, JSON.stringify(q))" in ui_src
|
||||||
|
# Counts: should appear in both add and update paths (2 occurrences minimum)
|
||||||
|
count = ui_src.count("sessionStorage.setItem('hermes-queue-'+sid")
|
||||||
|
assert count >= 2, f"Expected >=2 sessionStorage.setItem calls, found {count}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueueRestore:
|
||||||
|
"""Queue is restored from sessionStorage on session load when agent is idle."""
|
||||||
|
|
||||||
|
def test_restore_reads_session_storage(self):
|
||||||
|
"""sessions.js must read from sessionStorage in the idle-session load path."""
|
||||||
|
assert "sessionStorage.getItem('hermes-queue-'+sid)" in sess_src
|
||||||
|
|
||||||
|
def test_restore_uses_timestamp_guard(self):
|
||||||
|
"""Stale entries (created before last assistant response) must be dropped."""
|
||||||
|
assert '_queued_at' in sess_src
|
||||||
|
assert '_lastAsst' in sess_src
|
||||||
|
|
||||||
|
def test_restore_shows_toast(self):
|
||||||
|
"""User must see a toast notification when a queue is restored."""
|
||||||
|
assert 'queued message' in sess_src.lower() and 'restored' in sess_src.lower()
|
||||||
|
|
||||||
|
def test_restore_puts_text_in_composer(self):
|
||||||
|
"""First queued message goes into the composer input, not auto-sent."""
|
||||||
|
assert "_msg.value=_first.text" in sess_src
|
||||||
|
|
||||||
|
def test_restore_clears_stale_storage(self):
|
||||||
|
"""On timestamp mismatch, stale sessionStorage entry is removed."""
|
||||||
|
assert "sessionStorage.removeItem('hermes-queue-'+sid)" in sess_src
|
||||||
|
|
||||||
|
def test_restore_wrapped_in_try_catch(self):
|
||||||
|
"""sessionStorage access must be wrapped in try/catch (private browsing may block it)."""
|
||||||
|
# The restore block must have a catch that clears the bad key
|
||||||
|
assert "catch(_){sessionStorage.removeItem" in sess_src
|
||||||
|
|
||||||
|
def test_active_session_not_restored_as_draft(self):
|
||||||
|
"""When agent is active (INFLIGHT), queue restore must NOT run."""
|
||||||
|
# The restore block must be inside the else branch (idle path), not the INFLIGHT branch
|
||||||
|
inflight_pos = sess_src.find("if(INFLIGHT[sid]){")
|
||||||
|
restore_pos = sess_src.find("sessionStorage.getItem('hermes-queue-'")
|
||||||
|
else_pos = sess_src.find("}else{", inflight_pos)
|
||||||
|
assert restore_pos > else_pos, \
|
||||||
|
"Queue restore must be inside the else (idle) branch, not the INFLIGHT branch"
|
||||||
Reference in New Issue
Block a user