diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa25bab..88849105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **PR #2387** by @Michaelyklam (closes #2386) — Active-session and workspace-panel `localStorage` writes now degrade gracefully when browser storage quota is exhausted. The missed session/panel persistence writes now match the existing best-effort storage pattern so clicking sessions, creating chats, completing streams, and toggling the workspace panel no longer throw uncaught `QuotaExceededError` exceptions. + ## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n) ### Added diff --git a/static/boot.js b/static/boot.js index 492b87e0..617bc171 100644 --- a/static/boot.js +++ b/static/boot.js @@ -101,7 +101,7 @@ function _setWorkspacePanelMode(mode){ // Persist open/closed across refreshes (browse/preview → open; closed → closed) // Do NOT overwrite the user's "keep open" preference — only track runtime state // so that toggleWorkspacePanel(false) from the toolbar doesn't clear the setting. - localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed'); + try{localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed');}catch(_){} layout.classList.toggle('workspace-panel-collapsed',!open); if(_isCompactWorkspaceViewport()){ panel.classList.toggle('mobile-open',open); diff --git a/static/commands.js b/static/commands.js index 45441279..95c0e429 100644 --- a/static/commands.js +++ b/static/commands.js @@ -424,7 +424,7 @@ async function _applyManualCompressionResult(data, focusTopic, visibleCount, com S.messages=data.session.messages||[]; S.toolCalls=data.session.tool_calls||[]; clearLiveToolCards(); - localStorage.setItem('hermes-webui-session',S.session.session_id); + try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id); syncTopbar(); renderMessages(); diff --git a/static/messages.js b/static/messages.js index d21d1144..e673e528 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1454,7 +1454,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const _prevCost=(S.session&&S.session.estimated_cost)||0; S.session=d.session;S.messages=d.session.messages||[];if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!d.session._messages_truncated; if(S.session&&S.session.session_id){ - localStorage.setItem('hermes-webui-session',S.session.session_id); + try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id); } const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages); @@ -1824,7 +1824,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ clearLiveToolCards();if(!assistantText)removeThinking(); S.session=session;S.messages=(session.messages||[]).filter(m=>m&&m.role); if(S.session&&S.session.session_id){ - localStorage.setItem('hermes-webui-session',S.session.session_id); + try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id); } const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages); diff --git a/static/sessions.js b/static/sessions.js index 771e1d80..e1e5c6c6 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -416,7 +416,7 @@ async function newSession(flash, options={}){ S.session=data.session;S.messages=data.session.messages||[]; S.lastUsage={...(data.session.last_usage||{})}; if(flash)S.session._flash=true; - localStorage.setItem('hermes-webui-session',S.session.session_id); + try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} _setActiveSessionUrl(S.session.session_id); _setSessionViewedCount(S.session.session_id, S.session.message_count || 0); // Sync chat-header dropdown to the session's model so the UI reflects @@ -526,7 +526,7 @@ async function loadSession(sid){ if(typeof syncTopbar==='function') syncTopbar(); _setSessionViewedCount(S.session.session_id, Number(data.session.message_count || 0)); _clearSessionCompletionUnread(S.session.session_id); - localStorage.setItem('hermes-webui-session',S.session.session_id); + try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} _setActiveSessionUrl(S.session.session_id); const activeStreamId=S.session.active_stream_id||null; diff --git a/tests/test_issue2386_localstorage_quota.py b/tests/test_issue2386_localstorage_quota.py new file mode 100644 index 00000000..11775281 --- /dev/null +++ b/tests/test_issue2386_localstorage_quota.py @@ -0,0 +1,34 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def _script(path): + return (ROOT / path).read_text() + + +def _assert_storage_setitem_guarded(src, needle): + matches = [line.strip() for line in src.splitlines() if needle in line] + assert matches, f"expected at least one {needle} write" + for line in matches: + assert line.startswith("try{localStorage.setItem("), ( + f"localStorage quota errors must not escape from {needle} writes: {line}" + ) + assert "catch(_)" in line or "catch(e)" in line or "catch{}" in line + + +def test_active_session_localstorage_writes_ignore_quota_errors(): + """Session persistence writes are best-effort when the browser quota is full (#2386).""" + for path in ["static/sessions.js", "static/commands.js", "static/messages.js"]: + _assert_storage_setitem_guarded( + _script(path), + "localStorage.setItem('hermes-webui-session'", + ) + + +def test_workspace_panel_localstorage_write_ignores_quota_errors(): + """Workspace panel state should not break UI toggles if localStorage throws (#2386).""" + _assert_storage_setitem_guarded( + _script("static/boot.js"), + "localStorage.setItem('hermes-webui-workspace-panel'", + )