From 94a04ddd40f609c2c3bc4a101caea95fe16056df Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 20 Apr 2026 16:04:09 -0700 Subject: [PATCH] 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 --- CHANGELOG.md | 5 +++ static/sessions.js | 38 +++++++++++++++++++++ static/ui.js | 13 ++++++-- tests/test_issue660.py | 76 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 tests/test_issue660.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e34ffa..3005569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 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 ### Fixed diff --git a/static/sessions.js b/static/sessions.js index 047f42e..9a04380 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -83,6 +83,44 @@ async function loadSession(sid){ attachLiveStream(sid, activeStreamId, data.session.pending_attachments||[], {reconnecting:true}); } }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); S.messages=data.session.messages||[]; const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(data.session):null; diff --git a/static/ui.js b/static/ui.js index fa1bfea..dfb89b5 100644 --- a/static/ui.js +++ b/static/ui.js @@ -10,14 +10,23 @@ function _getSessionQueue(sid, create=false){ function queueSessionMessage(sid, payload){ if(!sid||!payload) return 0; 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; } function shiftQueuedSessionMessage(sid){ const q=_getSessionQueue(sid,false); if(!q.length) return null; 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; } function getQueuedSessionCount(sid){ diff --git a/tests/test_issue660.py b/tests/test_issue660.py new file mode 100644 index 0000000..b26a5c0 --- /dev/null +++ b/tests/test_issue660.py @@ -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"