diff --git a/CHANGELOG.md b/CHANGELOG.md index 86dc0b08..eeaa0e04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,39 @@ # Hermes Web UI -- Changelog -## [v0.51.29] — 2026-05-08 — 6-PR contributor batch (Release F: Docker hardening + login persistence + scroll/lineage fixes + i18n cleanup) +## [v0.51.30] — 2026-05-08 — 3-PR contributor batch (Release G: offline recovery + PWA hardening + opt-in session jump buttons + opt-in endless-scroll) + +### Added (3 PRs, all from @ai-ag2026) + +- **PR #1891** — Browser offline recovery and PWA cache hardening. Adds an offline/recovery banner that probes `/health` and auto-refreshes when Hermes is reachable again. Defers stream error handling while the browser is offline so reconnecting does not immediately surface a terminal chat error. Makes service-worker shell assets network-first with cache fallback (so local hotfixes are not hidden behind stale cached JS/CSS), while preserving offline-launch capability via `install` pre-caching of SHELL_ASSETS. Keeps PWA/native chrome colors aligned with the dark Hermes background. Stream-error deferral only triggers when the banner is visible OR `navigator.onLine===false` — so Hermes-up + browser-online flows errors through normally; no swallowed auth errors. Supersedes the recovery/PWA portion of #1888. + +- **PR #1928** — Opt-in session Start/End jump buttons (`session_jump_buttons` setting, default OFF). Adds an Appearance setting that surfaces a sticky `Start` pill (loads full history and jumps to beginning) and expands the existing scroll-to-bottom button into an `End` pill. Localized text, tooltip, and aria labels for the jump controls. The opt-in default keeps the existing UI unchanged for users who don't want the floating pills. + +- **PR #1929** — Opt-in session endless-scroll (`session_endless_scroll` setting, default OFF). Adds automatic prefetching of older transcript pages while scrolling upward (1.5x viewport prefetch window). Builds on #1927's viewport-preservation fix (shipped in v0.51.29) so prepended pages have scroll runway and don't jump. Replaces the previous auto-trigger-at-scrollTop<80 behavior — when the setting is OFF, users get the manual "Load earlier" button path (`_wireMessageWindowLoadEarlierButton`). + +### Conflict resolution applied during stage merge + +#1928 and #1929 both touch `static/ui.js`, `static/i18n.js`, `static/index.html`, `static/panels.js`, `api/config.py`. Mechanical conflicts (both add new settings keys / locale entries / HTML toggles / accessor branches) were resolved by keeping both — the features are independent opt-in toggles. The `static/ui.js` scroll-listener conflict required an intent-based resolution: #1929 INTENTIONALLY replaces the `el.scrollTop<80` auto-trigger block with the gated prefetch block, so the old block was removed. Test `tests/test_session_endless_scroll.py::test_scroll_listener_prefetches_older_messages_only_when_enabled` enforces this. CHANGELOG conflicts auto-resolved during rebase (took ours strategy). + +### Tests + +4960 → **4977 collected, 4977 passing, 0 regressions** (+17 net new). Full suite ~140s on Python 3.11 (HERMES_HOME isolated). JS syntax check (`node -c`) clean on all 6 modified `static/*.js` files. Browser API sanity harness (port 8789): all 11 endpoints + 20 QA tests PASS. **Manual browser verification on stage-325 server** (port 8789): both new settings toggles render in the Settings panel; `window._isSessionEndlessScrollEnabled()` correctly reflects toggle state; `_updateSessionStartJumpButton` function is exposed; offline-banner template + "Check now" button present in HTML. Opus advisor: SHIP-WITH-FIXES (one tracked race fast-follow + one i18n polish fast-follow, both non-blockers per Opus's own recommendation "Ship the batch"). + +### Pre-release verification + +- Full pytest under `HERMES_HOME` isolation: **4977 passed, 8 skipped, 1 xfailed, 2 xpassed, 8 subtests passed** in 140.56s. +- Browser API harness against stage-325 on port 8789: all 11 endpoints + 20 QA tests PASS (111.35s for QA phase). +- Manual browser verification: stage-325 server up on 8789, navigated to /, verified new toggles render in Settings panel, verified helper functions exposed correctly, verified offline-banner template loaded. +- `node -c` on all 6 modified `static/*.js` files: clean. +- Stage diff: 16 files, +649/-30. +- Opus advisor pass on stage-325 brief: VERDICT=SHIP-WITH-FIXES with explicit "Ship the batch" recommendation. Two fast-follows filed for tracking, neither is blocking. +- v0.51.29 carry-overs verified preserved (no in-batch changes to `_strip_workspace_prefix`, `evaluate_goal_after_turn`, `_profiles_match`, `mcp_server.py`). +- Pre-stamp re-fetch of all 3 PR heads: no contributor force-push during Opus window. + +### Follow-up items filed (non-blocking) + +- **Race between endless-scroll prefetch and Start-jump's `_ensureAllMessagesLoaded`** — with both opt-ins ON, an in-flight prefetch (started by 1.5x-viewport trigger) racing with `jumpToSessionStart` → `_ensureAllMessagesLoaded` could produce duplicate messages if the prefetch resolves last. Narrow window, but the fix is to gate `_ensureAllMessagesLoaded` on the existing `_loadingOlder` flag. +- **#1928 locale parity** — `session_jump_*` and `settings_*_session_jump_buttons` keys are English literals in ja/ru/es/de/zh/zh-Hant/pt/ko. Default-OFF + English fallback works, but breaks the locale-parity standard set by #1929 and #1891 in the same release. + ### Added (1 PR) diff --git a/ROADMAP.md b/ROADMAP.md index f39cdfad..6bfe7c39 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ > Web companion to the Hermes Agent CLI. Same workflows, browser-native. > -> Last updated: v0.51.29 (May 8, 2026) — 4960 tests collected — 6-PR Release F batch (Docker hardening + login persistence + scroll/lineage fixes + i18n cleanup) +> Last updated: v0.51.30 (May 8, 2026) — 4977 tests collected — 3-PR Release G batch (offline recovery + PWA hardening + opt-in session jump buttons + opt-in endless-scroll) > Test source: `pytest tests/ --collect-only -q` > Per-version detail: see [CHANGELOG.md](./CHANGELOG.md) diff --git a/TESTING.md b/TESTING.md index e9e7a980..cbdc0faf 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1835,8 +1835,8 @@ Bridged CLI sessions: --- -*Last updated: v0.51.29, May 8, 2026* -*Total automated tests collected: 4960* +*Last updated: v0.51.30, May 8, 2026* +*Total automated tests collected: 4977* *Regression gate: tests/test_regressions.py* *Run: pytest tests/ -v --timeout=60* *Source: /* diff --git a/api/auth.py b/api/auth.py index 5e9c6c4e..b6187455 100644 --- a/api/auth.py +++ b/api/auth.py @@ -19,7 +19,7 @@ logger = logging.getLogger(__name__) # ── Public paths (no auth required) ───────────────────────────────────────── PUBLIC_PATHS = frozenset({ - '/login', '/health', '/favicon.ico', + '/login', '/health', '/favicon.ico', '/sw.js', '/api/auth/login', '/api/auth/status', '/manifest.json', '/manifest.webmanifest', }) diff --git a/api/config.py b/api/config.py index b93d9ba2..27de96d2 100644 --- a/api/config.py +++ b/api/config.py @@ -3682,6 +3682,8 @@ _SETTINGS_DEFAULTS = { "theme": "dark", # light | dark | system "skin": "default", # accent color skin: default | ares | mono | slate | poseidon | sisyphus | charizard "font_size": "default", # small | default | large + "session_jump_buttons": False, # show Start/End transcript jump pills + "session_endless_scroll": False, # auto-load older transcript pages while scrolling upward "language": "en", # UI locale code; must match a key in static/i18n.js LOCALES "bot_name": os.getenv( "HERMES_WEBUI_BOT_NAME", "Hermes" @@ -3810,6 +3812,8 @@ _SETTINGS_BOOL_KEYS = { "show_thinking", "simplified_tool_calling", "api_redact_enabled", + "session_jump_buttons", + "session_endless_scroll", } # Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr') _SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$") diff --git a/static/boot.js b/static/boot.js index e6f1b7e7..2902718c 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1130,10 +1130,17 @@ function _normalizeAppearance(theme,skin){ // the meta tag. function _syncThemeColorMeta(){ try{ - const meta=document.getElementById('hermes-theme-color'); - if(!meta) return; const bg=getComputedStyle(document.documentElement).getPropertyValue('--bg').trim(); - if(bg) meta.setAttribute('content',bg); + if(!bg) return; + const known=document.getElementById('hermes-theme-color'); + if(known){ + known.setAttribute('content',bg); + known.removeAttribute('media'); + } + document.querySelectorAll('meta[name="theme-color"]').forEach(meta=>{ + meta.setAttribute('content',bg); + meta.removeAttribute('media'); + }); }catch(e){} } @@ -1303,11 +1310,13 @@ function applyBotName(){ window._simplifiedToolCalling=s.simplified_tool_calling!==false; window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact'); window._busyInputMode=(s.busy_input_mode||'queue'); + window._sessionEndlessScrollEnabled=!!s.session_endless_scroll; window._botName=s.bot_name||'Hermes'; if(s.default_model) window._defaultModel=s.default_model; // Persist default workspace so the blank new-chat page can show it // and workspace actions (New file/folder) work before the first session (#804). if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace; + window._sessionJumpButtonsEnabled=!!s.session_jump_buttons; const appearance=_normalizeAppearance(s.theme,s.skin); localStorage.setItem('hermes-theme',appearance.theme); _applyTheme(appearance.theme); @@ -1335,8 +1344,10 @@ function applyBotName(){ window._notificationsEnabled=false; window._showThinking=true; window._simplifiedToolCalling=true; + window._sessionJumpButtonsEnabled=false; window._sidebarDensity='compact'; window._busyInputMode='queue'; + window._sessionEndlessScrollEnabled=false; window._botName='Hermes'; _bootSettings={check_for_updates:false}; if(typeof setLocale==='function'){ diff --git a/static/i18n.js b/static/i18n.js index d32cfa84..8b86a209 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -5,6 +5,13 @@ const LOCALES = { en: { + offline_title: 'Connection lost', + offline_browser_detail: 'Your browser reports that this device is offline.', + offline_network_detail: 'Hermes is unreachable from this browser right now.', + offline_autorefresh: 'I will refresh this page automatically when Hermes is reachable again.', + offline_check_now: 'Check now', + offline_checking: 'Checking…', + offline_stream_waiting: 'Connection lost. Waiting to refresh…', _lang: 'en', _label: 'English', _speech: 'en-US', @@ -125,6 +132,10 @@ const LOCALES = { untitled: 'Untitled', n_messages: (n) => `${n} messages`, load_older_messages: '↑ Scroll up or click to load older messages', + session_jump_start: 'Start', + session_jump_start_label: 'Jump to beginning of session', + session_jump_end: 'End', + session_jump_end_label: 'Jump to end of session', queued_label: 'Sends after response', queued_count: (n) => n === 1 ? '1 queued' : `${n} queued`, queued_cancel: 'Cancel queued message', @@ -421,6 +432,12 @@ const LOCALES = { settings_update_check_failed: 'Update check failed', settings_label_workspace_panel_open: 'Keep workspace panel open by default', settings_desc_workspace_panel_open: 'When enabled, the workspace / file browser panel opens automatically with each new session. You can still close it manually at any time.', + settings_label_session_jump_buttons: 'Show session jump buttons', + settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.', + + settings_label_session_endless_scroll: 'Load older messages while scrolling up', + + settings_desc_session_endless_scroll: 'When enabled, older messages load automatically as you scroll upward. When disabled, use the older-messages button.', open_in_browser: 'Open in browser', settings_dropdown_conversation: 'Conversation', settings_dropdown_appearance: 'Appearance', @@ -1026,6 +1043,13 @@ const LOCALES = { }, ja: { + offline_title: '接続が切断されました', + offline_browser_detail: 'ブラウザはこのデバイスがオフラインだと報告しています。', + offline_network_detail: '現在、このブラウザからHermesに到達できません。', + offline_autorefresh: 'Hermesに再接続できたら、このページを自動的に更新します。', + offline_check_now: '今すぐ確認', + offline_checking: '確認中…', + offline_stream_waiting: '接続が切断されました。更新を待っています…', _lang: 'ja', _label: '日本語', _speech: 'ja-JP', @@ -1146,6 +1170,10 @@ const LOCALES = { untitled: '無題', n_messages: (n) => `${n} 件のメッセージ`, load_older_messages: '↑ 上にスクロール、またはクリックして過去のメッセージを読み込む', + session_jump_start: 'Start', + session_jump_start_label: 'Jump to beginning of session', + session_jump_end: 'End', + session_jump_end_label: 'Jump to end of session', queued_label: '応答後に送信', queued_count: (n) => `${n} 件キュー中`, queued_cancel: 'キューに入れたメッセージをキャンセル', @@ -1442,6 +1470,12 @@ const LOCALES = { settings_updates_disabled: 'アップデート確認は無効です', settings_label_workspace_panel_open: 'ワークスペースパネルをデフォルトで開いておく', settings_desc_workspace_panel_open: '有効にすると、新しいセッションごとにワークスペース/ファイルブラウザパネルが自動で開きます。手動でいつでも閉じられます。', + settings_label_session_jump_buttons: 'Show session jump buttons', + settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.', + + settings_label_session_endless_scroll: '上スクロールで古いメッセージを読み込む', + + settings_desc_session_endless_scroll: '有効にすると、上にスクロールしたとき古いメッセージを自動で読み込みます。無効の場合は古いメッセージボタンを使います。', open_in_browser: 'ブラウザで開く', settings_dropdown_conversation: '会話', settings_dropdown_appearance: '外観', @@ -2047,6 +2081,13 @@ const LOCALES = { }, ru: { + offline_title: 'Соединение потеряно', + offline_browser_detail: 'Браузер сообщает, что это устройство офлайн.', + offline_network_detail: 'Hermes сейчас недоступен из этого браузера.', + offline_autorefresh: 'Я автоматически обновлю страницу, когда Hermes снова станет доступен.', + offline_check_now: 'Проверить сейчас', + offline_checking: 'Проверка…', + offline_stream_waiting: 'Соединение потеряно. Ожидаю обновления…', _lang: 'ru', _label: 'Русский', _speech: 'ru-RU', @@ -2145,6 +2186,10 @@ const LOCALES = { untitled: 'Без названия', n_messages: (n) => `${n} сообщений`, load_older_messages: '↑ Прокрутите вверх или нажмите, чтобы загрузить ранние сообщения', + session_jump_start: 'Start', + session_jump_start_label: 'Jump to beginning of session', + session_jump_end: 'End', + session_jump_end_label: 'Jump to end of session', queued_label: 'Отправить после ответа', queued_count: (n) => n === 1 ? '1 в очереди' : `${n} в очереди`, queued_cancel: 'Отменить сообщение', @@ -2880,6 +2925,12 @@ const LOCALES = { settings_update_check_failed: 'Ошибка проверки обновлений', settings_label_workspace_panel_open: 'Открывать панель рабочей области по умолчанию', settings_desc_workspace_panel_open: 'При включении панель файлов будет открываться автоматически в каждой новой сессии.', + settings_label_session_jump_buttons: 'Show session jump buttons', + settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.', + + settings_label_session_endless_scroll: 'Загружать старые сообщения при прокрутке вверх', + + settings_desc_session_endless_scroll: 'Если включено, старые сообщения загружаются автоматически при прокрутке вверх. Если выключено, используйте кнопку загрузки старых сообщений.', open_in_browser: 'Открыть в браузере', settings_section_system_title: 'System', settings_tab_appearance: 'Appearance', @@ -3006,6 +3057,13 @@ const LOCALES = { }, es: { + offline_title: 'Conexión perdida', + offline_browser_detail: 'Tu navegador indica que este dispositivo está sin conexión.', + offline_network_detail: 'Hermes no está disponible desde este navegador ahora mismo.', + offline_autorefresh: 'Actualizaré esta página automáticamente cuando Hermes vuelva a estar disponible.', + offline_check_now: 'Comprobar ahora', + offline_checking: 'Comprobando…', + offline_stream_waiting: 'Conexión perdida. Esperando para actualizar…', _lang: 'es', _label: 'Español', _speech: 'es-ES', @@ -3102,6 +3160,10 @@ const LOCALES = { untitled: 'Sin título', n_messages: (n) => `${n} mensajes`, load_older_messages: '↑ Desplázate hacia arriba o haz clic para cargar mensajes anteriores', + session_jump_start: 'Start', + session_jump_start_label: 'Jump to beginning of session', + session_jump_end: 'End', + session_jump_end_label: 'Jump to end of session', queued_label: 'Enviar después de la respuesta', queued_count: (n) => n === 1 ? '1 en cola' : `${n} en cola`, queued_cancel: 'Cancelar mensaje en cola', @@ -3823,6 +3885,12 @@ const LOCALES = { settings_update_check_failed: 'Error al comprobar actualizaciones', settings_label_workspace_panel_open: 'Mantener panel de espacio abierto', settings_desc_workspace_panel_open: 'Al activar, el panel de archivos se abre automáticamente en cada nueva sesión. Aún puedes cerrarlo manualmente.', + settings_label_session_jump_buttons: 'Show session jump buttons', + settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.', + + settings_label_session_endless_scroll: 'Cargar mensajes antiguos al desplazarse hacia arriba', + + settings_desc_session_endless_scroll: 'Si está activado, los mensajes antiguos se cargan automáticamente al desplazarte hacia arriba. Si está desactivado, usa el botón de mensajes antiguos.', open_in_browser: 'Abrir en el navegador', settings_section_system_title: 'System', settings_tab_appearance: 'Appearance', @@ -3959,6 +4027,13 @@ const LOCALES = { }, de: { + offline_title: 'Verbindung verloren', + offline_browser_detail: 'Dein Browser meldet, dass dieses Gerät offline ist.', + offline_network_detail: 'Hermes ist von diesem Browser aus gerade nicht erreichbar.', + offline_autorefresh: 'Ich aktualisiere diese Seite automatisch, sobald Hermes wieder erreichbar ist.', + offline_check_now: 'Jetzt prüfen', + offline_checking: 'Prüfe…', + offline_stream_waiting: 'Verbindung verloren. Warte auf Aktualisierung…', _lang: 'de', _label: 'Deutsch', _speech: 'de-DE', @@ -4055,6 +4130,10 @@ const LOCALES = { untitled: 'Unbenannt', n_messages: (n) => `${n} Nachrichten`, load_older_messages: '↑ Nach oben scrollen oder klicken, um ältere Nachrichten zu laden', + session_jump_start: 'Start', + session_jump_start_label: 'Jump to beginning of session', + session_jump_end: 'End', + session_jump_end_label: 'Jump to end of session', queued_label: 'Wird nach Antwort gesendet', queued_count: (n) => n === 1 ? '1 in Warteschlange' : `${n} in Warteschlange`, queued_cancel: 'Nachricht abbrechen', @@ -4511,6 +4590,12 @@ const LOCALES = { settings_update_check_failed: 'Update-Prüfung fehlgeschlagen', settings_label_workspace_panel_open: 'Arbeitsbereich-Panel standardmäßig öffnen', settings_desc_workspace_panel_open: 'Wenn aktiviert, wird der Datei-Browser bei jeder neuen Sitzung automatisch geöffnet. Er kann jederzeit manuell geschlossen werden.', + settings_label_session_jump_buttons: 'Show session jump buttons', + settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.', + + settings_label_session_endless_scroll: 'Ältere Nachrichten beim Hochscrollen laden', + + settings_desc_session_endless_scroll: 'Wenn aktiviert, werden ältere Nachrichten beim Hochscrollen automatisch geladen. Wenn deaktiviert, nutzt du den Button für ältere Nachrichten.', workspace_drag_hint: 'Ziehen zum Neuordnen', workspace_reorder_failed: 'Neuordnen fehlgeschlagen', @@ -4916,6 +5001,13 @@ const LOCALES = { }, zh: { + offline_title: '连接已断开', + offline_browser_detail: '浏览器报告此设备当前离线。', + offline_network_detail: '此浏览器当前无法连接到 Hermes。', + offline_autorefresh: '当 Hermes 可访问时,我会自动刷新此页面。', + offline_check_now: '立即检查', + offline_checking: '正在检查…', + offline_stream_waiting: '连接已断开。正在等待刷新…', _lang: 'zh', _label: '\u7b80\u4f53\u4e2d\u6587', _speech: 'zh-CN', @@ -5012,6 +5104,10 @@ const LOCALES = { untitled: '\u672a\u547d\u540d', n_messages: (n) => `${n} \u6761\u6d88\u606f`, load_older_messages: '↑ 向上滚动或点击加载更早的消息', + session_jump_start: 'Start', + session_jump_start_label: 'Jump to beginning of session', + session_jump_end: 'End', + session_jump_end_label: 'Jump to end of session', queued_label: '响应后发送', queued_count: (n) => n === 1 ? '1 条排队' : `${n} 条排队`, queued_cancel: '取消排队消息', @@ -5730,6 +5826,12 @@ const LOCALES = { settings_update_check_failed: '更新检查失败', settings_label_workspace_panel_open: '默认保持工作区面板打开', settings_desc_workspace_panel_open: '启用后,工作区/文件浏览器面板会在每次新会话时自动打开。您仍可随时手动关闭。', + settings_label_session_jump_buttons: 'Show session jump buttons', + settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.', + + settings_label_session_endless_scroll: '向上滚动时加载更早的消息', + + settings_desc_session_endless_scroll: '启用后,向上滚动时会自动加载更早的消息。禁用时请使用加载更早消息按钮。', open_in_browser: '在浏览器中打开', settings_section_system_title: 'System', settings_tab_appearance: 'Appearance', @@ -5964,6 +6066,10 @@ const LOCALES = { untitled: '\u672a\u547d\u540d', n_messages: (n) => `${n} \u689d\u8a0a\u606f`, load_older_messages: '↑ 向上捲動或點擊以載入較早的訊息', + session_jump_start: 'Start', + session_jump_start_label: 'Jump to beginning of session', + session_jump_end: 'End', + session_jump_end_label: 'Jump to end of session', model_unavailable: '\uff08\u4e0d\u53ef\u7528\uff09', model_unavailable_title: '\u6b64\u6a21\u578b\u5df2\u7d93\u4e0d\u5728\u7576\u524d provider \u5217\u8868\u4e2d', provider_mismatch_warning: (m,p)=>`\"${m}\" \u53ef\u80fd\u7121\u6cd5\u5728\u7576\u524d\u914d\u7f6e\u7684\u63d0\u4f9b\u8005 (${p}) \u4e0b\u904b\u4f5c\u3002\u5c1a\u9001\uff0c\u6216\u5728\u7d42\u7aef\u57f7\u884c \`hermes model\` \u5207\u63db\u3002`, @@ -6136,6 +6242,12 @@ const LOCALES = { settings_update_check_failed: '更新檢查失敗', settings_label_workspace_panel_open: '預設保持工作區面板開啓', settings_desc_workspace_panel_open: '啟用後,工作區/檔案瀏覽器面板會在每次新會話時自動開啓。您仍可隨時手動關閉。', + settings_label_session_jump_buttons: 'Show session jump buttons', + settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.', + + settings_label_session_endless_scroll: '向上捲動時載入較早訊息', + + settings_desc_session_endless_scroll: '啟用後,向上捲動時會自動載入較早訊息。停用時請使用載入較早訊息按鈕。', open_in_browser: '在瀏覽器中開啓', settings_dropdown_conversation: '對話', settings_dropdown_appearance: '外觀', @@ -6288,6 +6400,10 @@ const LOCALES = { downloading: (filename) => `正在下載 ${filename}…`, n_messages: (n) => `${n} 則訊息`, load_older_messages: '↑ 向上捲動或點擊以載入較早的訊息', + session_jump_start: 'Start', + session_jump_start_label: 'Jump to beginning of session', + session_jump_end: 'End', + session_jump_end_label: 'Jump to end of session', onboarding_api_key_help_prefix: '\u900f\u904e\u4ee5\u4e0b\u65b9\u5f0f\u5132\u5b58\u70ba Hermes .env \u6a94\u6848\u4e2d\u7684\u6a5f\u5bc6', onboarding_api_key_label: 'API \u91d1\u9470', onboarding_api_key_placeholder: '\u7559\u7a7a\u4ee5\u4fdd\u7559\u5df2\u5132\u5b58\u7684\u91d1\u9470', @@ -6857,6 +6973,13 @@ const LOCALES = { }, pt: { + offline_title: 'Conexão perdida', + offline_browser_detail: 'O navegador informa que este dispositivo está offline.', + offline_network_detail: 'O Hermes está inacessível neste navegador agora.', + offline_autorefresh: 'Vou atualizar esta página automaticamente quando o Hermes voltar a responder.', + offline_check_now: 'Verificar agora', + offline_checking: 'Verificando…', + offline_stream_waiting: 'Conexão perdida. Aguardando para atualizar…', _lang: 'pt', _label: 'Português', _speech: 'pt-BR', @@ -6906,6 +7029,10 @@ const LOCALES = { untitled: 'Sem título', n_messages: (n) => `${n} mensagens`, load_older_messages: '↑ Role para cima ou clique para carregar mensagens mais antigas', + session_jump_start: 'Start', + session_jump_start_label: 'Jump to beginning of session', + session_jump_end: 'End', + session_jump_end_label: 'Jump to end of session', queued_label: 'Envia após a resposta', queued_count: (n) => n === 1 ? '1 na fila' : `${n} na fila`, queued_cancel: 'Cancelar mensagem na fila', @@ -7170,6 +7297,12 @@ const LOCALES = { settings_update_check_failed: 'Falha ao verificar updates', settings_label_workspace_panel_open: 'Manter painel workspace aberto por padrão', settings_desc_workspace_panel_open: 'Quando ativo, o painel workspace abre automaticamente com cada nova sessão.', + settings_label_session_jump_buttons: 'Show session jump buttons', + settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.', + + settings_label_session_endless_scroll: 'Carregar mensagens antigas ao rolar para cima', + + settings_desc_session_endless_scroll: 'Quando ativado, mensagens antigas carregam automaticamente ao rolar para cima. Quando desativado, use o botão de mensagens antigas.', open_in_browser: 'Abrir no navegador', settings_dropdown_conversation: 'Conversa', settings_dropdown_appearance: 'Aparência', @@ -7706,6 +7839,13 @@ const LOCALES = { disable_auth_confirm_title: 'Desativar proteção por senha', }, ko: { + offline_title: '연결이 끊겼습니다', + offline_browser_detail: '브라우저가 이 장치가 오프라인이라고 보고합니다.', + offline_network_detail: '현재 이 브라우저에서 Hermes에 연결할 수 없습니다.', + offline_autorefresh: 'Hermes에 다시 연결되면 이 페이지를 자동으로 새로고침합니다.', + offline_check_now: '지금 확인', + offline_checking: '확인 중…', + offline_stream_waiting: '연결이 끊겼습니다. 새로고침을 기다리는 중…', _lang: 'ko', _label: '한국어', _speech: 'ko-KR', @@ -7802,6 +7942,10 @@ const LOCALES = { untitled: '제목 없음', n_messages: (n) => `${n}개 메시지`, load_older_messages: '↑ 위로 스크롤하거나 클릭하여 이전 메시지 불러오기', + session_jump_start: 'Start', + session_jump_start_label: 'Jump to beginning of session', + session_jump_end: 'End', + session_jump_end_label: 'Jump to end of session', queued_label: 'Sends after response', queued_count: (n) => n === 1 ? '1 queued' : `${n} queued`, queued_cancel: 'Cancel queued message', @@ -8089,6 +8233,12 @@ const LOCALES = { settings_update_check_failed: 'Update check failed', settings_label_workspace_panel_open: '기본으로 워크스페이스 패널 열기', settings_desc_workspace_panel_open: '활성화하면 새 세션마다 워크스페이스/파일 브라우저 패널이 자동으로 열립니다. 언제든지 수동으로 닫을 수 있습니다.', + settings_label_session_jump_buttons: 'Show session jump buttons', + settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.', + + settings_label_session_endless_scroll: '위로 스크롤할 때 이전 메시지 불러오기', + + settings_desc_session_endless_scroll: '활성화하면 위로 스크롤할 때 이전 메시지를 자동으로 불러옵니다. 비활성화하면 이전 메시지 버튼을 사용합니다.', open_in_browser: '브라우저에서 열기', settings_dropdown_conversation: '대화', settings_dropdown_appearance: '외형', @@ -8832,6 +8982,11 @@ function applyLocaleToDOM() { const val = t(key); if (val && val !== key) el.placeholder = val; }); + document.querySelectorAll('[data-i18n-aria-label]').forEach(el => { + const key = el.getAttribute('data-i18n-aria-label'); + const val = t(key); + if (val && val !== key) el.setAttribute('aria-label', val); + }); if (typeof syncAppTitlebar === 'function') syncAppTitlebar(); } diff --git a/static/index.html b/static/index.html index d21c64b4..319901e1 100644 --- a/static/index.html +++ b/static/index.html @@ -21,7 +21,7 @@ - + @@ -297,7 +297,8 @@
- + +
+
diff --git a/static/manifest.json b/static/manifest.json index 2e337271..caa9570f 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -4,8 +4,8 @@ "description": "Hermes AI Agent Web UI", "start_url": "./", "display": "standalone", - "background_color": "#1a1a1a", - "theme_color": "#1a1a1a", + "background_color": "#0D0D1A", + "theme_color": "#0D0D1A", "orientation": "portrait-primary", "icons": [ { diff --git a/static/messages.js b/static/messages.js index 3c196937..d7122b9f 100644 --- a/static/messages.js +++ b/static/messages.js @@ -32,6 +32,19 @@ function _markActiveSessionViewedOnReturn() { if(typeof renderSessionListFromCache==='function') renderSessionListFromCache(); } +function _deferStreamErrorIfOffline(){ + if(typeof isOfflineBannerVisible==='function' && isOfflineBannerVisible()){ + setComposerStatus(t('offline_stream_waiting')); + return true; + } + if(typeof showOfflineBanner==='function' && navigator.onLine===false){ + showOfflineBanner('browser'); + setComposerStatus(t('offline_stream_waiting')); + return true; + } + return false; +} + document.addEventListener('visibilitychange', _markActiveSessionViewedOnReturn); window.addEventListener('focus', _markActiveSessionViewedOnReturn); // TTS: pause speech synthesis when user focuses the composer (#499) @@ -1207,6 +1220,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ source.addEventListener('error',async e=>{ source.close(); + if(_deferStreamErrorIfOffline()) return; if(_terminalStateReached || _streamFinalized){ _closeSource(); return; @@ -1223,13 +1237,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{withCredentials:true})); return; } - }catch(_){} + }catch(_){ + if(_deferStreamErrorIfOffline()) return; + } if(await _restoreSettledSession()) return; + if(_deferStreamErrorIfOffline()) return; _handleStreamError(); },1500); return; } if(await _restoreSettledSession()) return; + if(_deferStreamErrorIfOffline()) return; _handleStreamError(); }); diff --git a/static/panels.js b/static/panels.js index 14d210d6..fd10c820 100644 --- a/static/panels.js +++ b/static/panels.js @@ -4251,6 +4251,8 @@ function _appearancePayloadFromUi(){ theme: ($('settingsTheme')||{}).value || localStorage.getItem('hermes-theme') || 'dark', skin: ($('settingsSkin')||{}).value || localStorage.getItem('hermes-skin') || 'default', font_size: ($('settingsFontSize')||{}).value || localStorage.getItem('hermes-font-size') || 'default', + session_jump_buttons: !!($('settingsSessionJumpButtons')||{}).checked, + session_endless_scroll: !!($('settingsSessionEndlessScroll')||{}).checked, }; } @@ -4298,6 +4300,11 @@ async function _autosaveAppearanceSettings(payload){ if(saved&&saved.font_size){ localStorage.setItem('hermes-font-size',saved.font_size); } + if(saved){ + window._sessionJumpButtonsEnabled=!!saved.session_jump_buttons; + if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs(); + } + window._sessionEndlessScrollEnabled=!!(saved&&saved.session_endless_scroll); _setAppearanceAutosaveStatus('saved'); }catch(e){ console.warn('[settings] appearance autosave failed', e); @@ -4454,6 +4461,17 @@ async function loadSettingsPanel(){ const fontSizeSel=$('settingsFontSize'); if(fontSizeSel) fontSizeSel.value=fontSizeVal; if(typeof _syncFontSizePicker==='function') _syncFontSizePicker(fontSizeVal); + const jumpButtonsCb=$('settingsSessionJumpButtons'); + if(jumpButtonsCb){ + jumpButtonsCb.checked=!!settings.session_jump_buttons; + window._sessionJumpButtonsEnabled=jumpButtonsCb.checked; + jumpButtonsCb.onchange=function(){ + window._sessionJumpButtonsEnabled=this.checked; + if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs(); + _scheduleAppearanceAutosave(); + }; + } + if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs(); // Workspace panel default-open toggle (localStorage-backed) // Uses a separate key (hermes-webui-workspace-panel-pref) so that // closing the panel via toolbar X does not clear the user's preference. @@ -4470,6 +4488,15 @@ async function loadSettingsPanel(){ else if(!open&&_workspacePanelMode!=='closed') toggleWorkspacePanel(false); }; } + const endlessScrollCb=$('settingsSessionEndlessScroll'); + if(endlessScrollCb){ + endlessScrollCb.checked=!!settings.session_endless_scroll; + window._sessionEndlessScrollEnabled=endlessScrollCb.checked; + endlessScrollCb.onchange=function(){ + window._sessionEndlessScrollEnabled=this.checked; + _scheduleAppearanceAutosave(); + }; + } const resolvedLanguage=(typeof resolvePreferredLocale==='function') ? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang')) : (settings.language || localStorage.getItem('hermes-lang') || 'en'); @@ -5124,8 +5151,11 @@ function _applySavedSettingsUi(saved, body, opts){ window._notificationsEnabled=body.notifications_enabled; window._showThinking=body.show_thinking!==false; window._simplifiedToolCalling=body.simplified_tool_calling!==false; + window._sessionJumpButtonsEnabled=!!body.session_jump_buttons; + if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs(); window._sidebarDensity=sidebarDensity==='detailed'?'detailed':'compact'; window._busyInputMode=body.busy_input_mode||'queue'; + window._sessionEndlessScrollEnabled=!!body.session_endless_scroll; window._botName=body.bot_name||'Hermes'; if(typeof applyBotName==='function') applyBotName(); if(typeof setLocale==='function') setLocale(language); @@ -5221,6 +5251,8 @@ async function saveSettings(andClose){ body.theme=theme; body.skin=skin; body.font_size=fontSize; + body.session_jump_buttons=!!($('settingsSessionJumpButtons')||{}).checked; + body.session_endless_scroll=!!($('settingsSessionEndlessScroll')||{}).checked; body.language=language; body.show_token_usage=showTokenUsage; body.show_tps=showTps; diff --git a/static/style.css b/static/style.css index 26e32b05..d5f4a2f4 100644 --- a/static/style.css +++ b/static/style.css @@ -536,6 +536,14 @@ .reconnect-banner.visible{display:flex;} .reconnect-btn{padding:6px 12px;border-radius:8px;font-size:12px;font-weight:600;background:var(--accent-bg-strong);border:1px solid var(--accent-bg-strong);color:var(--accent-text);cursor:pointer;} .reconnect-btn:hover{background:var(--accent-bg-strong);} + .offline-banner{position:fixed;left:0;right:0;top:0;z-index:1200;display:none;align-items:center;justify-content:space-between;gap:14px;padding:12px 18px;border-bottom:1px solid color-mix(in srgb,var(--warning,#f6c343) 55%,var(--surface));background:color-mix(in srgb,var(--bg-1,#0d0d1a) 88%,var(--warning,#f6c343));color:var(--text);box-shadow:0 12px 40px rgba(0,0,0,.22);backdrop-filter:blur(10px);} + .offline-banner.visible{display:flex;} + .offline-copy{display:flex;flex-direction:column;gap:3px;min-width:0;font-size:13px;line-height:1.35;} + .offline-copy strong{color:var(--warning,#f6c343);font-size:13px;letter-spacing:.08em;text-transform:uppercase;} + .offline-copy span{color:var(--muted);} + .offline-action{flex-shrink:0;padding:7px 13px;border-radius:9px;border:1px solid color-mix(in srgb,var(--warning,#f6c343) 48%,var(--surface));background:color-mix(in srgb,var(--warning,#f6c343) 12%,var(--surface));color:var(--warning,#f6c343);font-size:12px;font-weight:700;cursor:pointer;} + .offline-action:hover{background:color-mix(in srgb,var(--warning,#f6c343) 20%,var(--surface));} + .offline-action[disabled]{cursor:wait;opacity:.65;} .agent-health-banner{position:sticky;bottom:0;z-index:4;display:none;align-items:center;justify-content:space-between;gap:12px;margin:10px auto 0;max-width:var(--msg-max);width:calc(100% - 40px);padding:12px 16px;border:1px solid color-mix(in srgb,var(--error) 55%,var(--surface));border-radius:12px;background:color-mix(in srgb,var(--error) 14%,var(--surface));color:var(--text);box-shadow:0 10px 32px rgba(0,0,0,.16);} .agent-health-banner.visible{display:flex;} .agent-health-copy{display:flex;flex-direction:column;gap:3px;min-width:0;font-size:13px;line-height:1.35;} @@ -772,9 +780,16 @@ .workspace-toggle-btn:disabled{opacity:.38;cursor:not-allowed;} .chip.model{color:var(--accent-text);border-color:var(--accent-bg-strong);background:var(--accent-bg);} .messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior-y:contain;overflow-anchor:none;} - /* sticky-first-child: button is first child of .messages so its natural position is above viewport; sticky+bottom:16px pins it there when visible */ - .scroll-to-bottom-btn{position:sticky;bottom:16px;align-self:flex-end;margin-right:20px;width:32px;height:32px;border-radius:50%;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:10;transition:color .12s,border-color .12s,background .12s;} + /* sticky-first-child: button is early in .messages so its natural position is above viewport; sticky+bottom pins it there when visible */ + .scroll-to-bottom-btn{position:sticky;bottom:16px;align-self:flex-end;margin-right:20px;width:32px;height:32px;border-radius:50%;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:10;transition:color .12s,border-color .12s,background .12s,transform .12s;} .scroll-to-bottom-btn:hover{color:var(--text);border-color:var(--border);background:var(--hover-bg);} + .session-jump-btn__text{display:none;} + .session-jump-btn{position:sticky;align-self:flex-end;flex:0 0 32px;min-height:32px;margin-right:20px;height:32px;border-radius:999px;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:12px;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:5px;padding:0 11px;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:11;transition:color .12s,border-color .12s,background .12s,opacity .12s,transform .12s;} + .session-jump-btn:hover{color:var(--text);border-color:var(--border);background:var(--hover-bg);transform:translateY(-1px);} + .session-jump-btn--start{top:16px;margin-bottom:-36px;} + .messages.session-nav-enabled .scroll-to-bottom-btn{width:auto;min-width:32px;border-radius:999px;font-size:12px;font-weight:600;gap:5px;padding:0 11px;} + .messages.session-nav-enabled .scroll-to-bottom-btn:hover{transform:translateY(-1px);} + .messages.session-nav-enabled .session-jump-btn__text{display:inline;} .messages-inner{margin:0 auto;width:100%;padding:20px 24px 32px;display:flex;flex-direction:column;} @media(min-width:1400px){.messages-inner{max-width:1100px;}} @media(min-width:1800px){.messages-inner{max-width:1200px;}} diff --git a/static/sw.js b/static/sw.js index 3fe629e4..ebfccf35 100644 --- a/static/sw.js +++ b/static/sw.js @@ -68,7 +68,7 @@ self.addEventListener('activate', (event) => { // - API calls (/api/*, /stream) → always network (never cache) // - Login assets → always network (never cache stale auth code) // - Page navigations → network-first so auth redirects/cookies are honored -// - Shell assets → cache-first with network fallback +// - Shell assets → network-first with cache fallback // - Everything else → network-only self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); @@ -131,7 +131,7 @@ self.addEventListener('fetch', (event) => { return; } - // Only explicit shell assets use cache-first. Everything else should hit the + // Only explicit shell assets are cached. Everything else should hit the // network so stale one-off files (especially auth/login scripts) do not get // trapped in CacheStorage until a manual cache clear. const scopePath = new URL(self.registration.scope).pathname; @@ -141,21 +141,22 @@ self.addEventListener('fetch', (event) => { const shellPath = './' + relPath.replace(/^\/+/, '') + url.search; if (!SHELL_ASSETS.includes(shellPath)) return; - // Shell assets: cache-first + // Shell assets: network-first with cache fallback. This keeps offline support + // but avoids executing stale JS/CSS after a local hotfix when WEBUI_VERSION + // has not changed yet (e.g. before a guarded restart updates the ?v token). event.respondWith( - caches.match(event.request).then((cached) => { - if (cached) return cached; - return fetch(event.request).then((response) => { - // Cache successful GET responses for shell assets - if ( - event.request.method === 'GET' && - response.status === 200 - ) { - const clone = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); - } - return response; - }); - }) + fetch(event.request).then((response) => { + if ( + event.request.method === 'GET' && + response.status === 200 + ) { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); + } + return response; + }).catch(() => caches.match(event.request).then((cached) => cached || new Response('Offline', { + status: 503, + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }))) ); }); diff --git a/static/ui.js b/static/ui.js index 3e95e1b0..3827d926 100644 --- a/static/ui.js +++ b/static/ui.js @@ -11,6 +11,105 @@ const MAX_UPLOAD_MB=Math.round(MAX_UPLOAD_BYTES/1024/1024); // single-threaded so only one done event fires at a time in practice. let _queueDrainSid=null; const $=id=>document.getElementById(id); +const OFFLINE_RECHECK_MS=2500; +let _offlineVisible=false; +let _offlineReason='browser'; +let _offlineProbeTimer=null; +let _offlineChecking=false; +let _offlineProbePromise=null; +let _offlineHealthProbePromise=null; +let _offlineRawFetch=null; +let _offlineFetchPatched=false; +function _browserReportsOnline(){return !('onLine' in navigator)||navigator.onLine!==false;} +function _offlineHealthUrl(){const url=new URL('health',document.baseURI||location.href);url.searchParams.set('offline_probe',String(Date.now()));return url.href;} +function _setOfflineChecking(checking){ + _offlineChecking=!!checking; + const btn=$('offlineCheckNow'); + if(btn){btn.disabled=_offlineChecking;btn.textContent=_offlineChecking?t('offline_checking'):t('offline_check_now');} +} +function _renderOfflineBanner(){ + const banner=$('offlineBanner'); + if(!banner)return; + const detail=$('offlineDetails'); + if(detail)detail.textContent=t(_offlineReason==='browser'?'offline_browser_detail':'offline_network_detail'); + const title=$('offlineTitle'); + if(title)title.textContent=t('offline_title'); + const auto=$('offlineAutorefresh'); + if(auto)auto.textContent=t('offline_autorefresh'); + _setOfflineChecking(_offlineChecking); + banner.hidden=false; + banner.classList.add('visible'); +} +function _startOfflineProbeTimer(){ + if(_offlineProbeTimer)return; + _offlineProbeTimer=setInterval(()=>{checkOfflineRecoveryNow();},OFFLINE_RECHECK_MS); +} +function _stopOfflineProbeTimer(){ + if(_offlineProbeTimer){clearInterval(_offlineProbeTimer);_offlineProbeTimer=null;} +} +function showOfflineBanner(reason){ + _offlineVisible=true; + _offlineReason=reason||(_browserReportsOnline()?'network':'browser'); + _renderOfflineBanner(); + _startOfflineProbeTimer(); +} +function isOfflineBannerVisible(){return _offlineVisible;} +function _hideOfflineBanner(){ + _offlineVisible=false; + _stopOfflineProbeTimer(); + _setOfflineChecking(false); + const banner=$('offlineBanner'); + if(banner){banner.classList.remove('visible');banner.hidden=true;} +} +async function _probeOfflineRecovery(){ + if(_offlineHealthProbePromise)return _offlineHealthProbePromise; + _offlineHealthProbePromise=(async()=>{ + const fetcher=_offlineRawFetch||window.fetch.bind(window); + try{ + const res=await fetcher(_offlineHealthUrl(),{cache:'no-store',credentials:'include'}); + return !!(res&&res.ok); + }catch(_){return false;} + })(); + try{return await _offlineHealthProbePromise;} + finally{_offlineHealthProbePromise=null;} +} +async function checkOfflineRecoveryNow(){ + if(_offlineProbePromise)return _offlineProbePromise; + _offlineProbePromise=(async()=>{ + if(!_offlineVisible)return false; + if(!_browserReportsOnline()){showOfflineBanner('browser');return false;} + _setOfflineChecking(true); + const ok=await _probeOfflineRecovery(); + _setOfflineChecking(false); + if(ok){_stopOfflineProbeTimer();window.location.reload();return true;} + showOfflineBanner('network'); + return false; + })(); + try{return await _offlineProbePromise;} + finally{_offlineProbePromise=null;} +} +function _isAbortError(e){return !!(e&&(e.name==='AbortError'||e.code===20));} +function _patchOfflineFetch(){ + if(_offlineFetchPatched||typeof window.fetch!=='function')return; + _offlineFetchPatched=true; + _offlineRawFetch=window.fetch.bind(window); + window.fetch=async function(...args){ + try{return await _offlineRawFetch(...args);} + catch(e){ + if(!_browserReportsOnline())showOfflineBanner('browser'); + else if(e instanceof TypeError&&!_isAbortError(e))void _probeOfflineRecovery().then(ok=>{if(!ok)showOfflineBanner('network');}); + throw e; + } + }; +} +function initOfflineMonitor(){ + _patchOfflineFetch(); + window.addEventListener('offline',()=>showOfflineBanner('browser')); + window.addEventListener('online',()=>{if(_offlineVisible)checkOfflineRecoveryNow();}); + if(!_browserReportsOnline())showOfflineBanner('browser'); +} +if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',initOfflineMonitor,{once:true}); +else initOfflineMonitor(); // Redirect to login when the server responds with 401 (auth session expired). // Handles iOS PWA standalone mode and keeps subpath mounts like /hermes/ from // escaping to the personal site root /login. @@ -185,6 +284,9 @@ function _messageRenderableMessageCount(){ function _messageHiddenBeforeCount(){ return Math.max(0,_messageRenderableMessageCount()-_currentMessageRenderWindowSize()); } +function _isSessionEndlessScrollEnabled(){ + return window._sessionEndlessScrollEnabled===true; +} function _wireMessageWindowLoadEarlierButton(){ const indicator=$('loadOlderIndicator'); if(!indicator) return; @@ -205,6 +307,48 @@ function _showEarlierRenderedMessages(){ } _scrollPinned=false; } +function _isSessionJumpButtonsEnabled(){ + return window._sessionJumpButtonsEnabled===true; +} +function _applySessionNavigationPrefs(){ + const container=$('messages'); + if(container) container.classList.toggle('session-nav-enabled',_isSessionJumpButtonsEnabled()); + _updateSessionStartJumpButton(); +} +function _updateSessionStartJumpButton(){ + const btn=$('jumpToSessionStartBtn'); + const container=$('messages'); + if(!btn||!container) return; + if(!_isSessionJumpButtonsEnabled()){ + btn.style.display='none'; + return; + } + const hasSession=!!(S&&S.session&&S.messages&&S.messages.length); + const awayFromStart=container.scrollTop>Math.max(240,container.clientHeight*0.35); + const hasScrollableHistory=container.scrollHeight>container.clientHeight+Math.max(240,container.clientHeight*0.35); + const canRevealStart=hasScrollableHistory||_messageHiddenBeforeCount()>0||!!(typeof _messagesTruncated!=='undefined'&&_messagesTruncated); + btn.style.display=(hasSession&&canRevealStart&&awayFromStart)?'flex':'none'; +} +async function jumpToSessionStart(){ + const container=$('messages'); + if(!container||!S.session) return; + _scrollPinned=false; + _messageUserUnpinned=true; + _programmaticScroll=true; + try{ + if(typeof _ensureAllMessagesLoaded==='function') await _ensureAllMessagesLoaded(); + _messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount()); + renderMessages({ preserveScroll:true }); + requestAnimationFrame(()=>{ + container.scrollTop=0; + _updateSessionStartJumpButton(); + requestAnimationFrame(()=>{ _programmaticScroll=false; }); + }); + }catch(e){ + console.warn('jumpToSessionStart failed:',e); + _programmaticScroll=false; + } +} const DASHBOARD_STATUS_TTL_MS=60000; let _dashboardStatusCache=null; @@ -1574,8 +1718,12 @@ if (typeof window !== 'undefined') window._resetScrollDirectionTracker = _resetS } // #1360 const btn=$('scrollToBottomBtn'); if(btn) btn.style.display=_scrollPinned?'none':'flex'; - // Load older messages when scrolled near the top - if(el.scrollTop<80 && typeof _messagesTruncated!=='undefined' && _messagesTruncated && typeof _loadOlderMessages==='function'){ + if(typeof _updateSessionStartJumpButton==='function') _updateSessionStartJumpButton(); + // Prefetch older messages before the reader hits the hard top. Prepending + // then preserving scrollTop is seamless only if there is runway left for + // the user's continued upward wheel/touch movement. + const olderPrefetchPx=Math.max(600,el.clientHeight*1.5); + if(_isSessionEndlessScrollEnabled()&&el.scrollTop{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();}); requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();}); @@ -4626,6 +4776,7 @@ function renderMessages(options){ const renderVisWithIdx=visWithIdx.slice(windowStart); const firstRenderedRawIdx=renderVisWithIdx.length?renderVisWithIdx[0].rawIdx:Infinity; const hasServerOlder=!!(typeof _messagesTruncated!=='undefined' && _messagesTruncated && S.messages.length>0); + if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs(); if(hiddenBeforeCount>0 || hasServerOlder){ const indicator=document.createElement('button'); indicator.type='button'; diff --git a/tests/test_offline_banner.py b/tests/test_offline_banner.py new file mode 100644 index 00000000..4942d8fe --- /dev/null +++ b/tests/test_offline_banner.py @@ -0,0 +1,72 @@ +"""Regression coverage for the browser-offline banner and auto-refresh loop.""" + +from __future__ import annotations + +import pathlib + + +REPO_ROOT = pathlib.Path(__file__).parent.parent +UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8") +MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8") +INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8") +STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8") +I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8") + + +def test_offline_banner_markup_styles_and_copy_exist(): + assert 'id="offlineBanner"' in INDEX_HTML + assert 'role="status"' in INDEX_HTML + assert 'aria-live="assertive"' in INDEX_HTML + assert 'onclick="checkOfflineRecoveryNow()"' in INDEX_HTML + assert ".offline-banner" in STYLE_CSS + assert ".offline-banner.visible" in STYLE_CSS + assert ".offline-action[disabled]" in STYLE_CSS + for key in ( + "offline_title", + "offline_browser_detail", + "offline_network_detail", + "offline_autorefresh", + "offline_check_now", + "offline_checking", + "offline_stream_waiting", + ): + assert key in I18N_JS + + +def test_offline_monitor_patches_fetch_and_auto_reloads_after_health_probe(): + assert "const OFFLINE_RECHECK_MS=2500" in UI_JS + assert "window.fetch=async function(...args)" in UI_JS + assert "window.addEventListener('offline',()=>showOfflineBanner('browser'))" in UI_JS + assert "window.addEventListener('online',()=>{if(_offlineVisible)checkOfflineRecoveryNow();})" in UI_JS + assert "setInterval(()=>{checkOfflineRecoveryNow();},OFFLINE_RECHECK_MS)" in UI_JS + assert "new URL('health',document.baseURI||location.href)" in UI_JS + assert "window.location.reload()" in UI_JS + + +def test_offline_recovery_probe_is_serialized_and_stops_timer_before_reload(): + assert "let _offlineProbePromise=null" in UI_JS + assert "let _offlineHealthProbePromise=null" in UI_JS + assert "if(!_offlineVisible)return false;" in UI_JS + assert "if(!_offlineVisible&&!_offlineFetchPatched)return false;" not in UI_JS + assert "finally{_offlineProbePromise=null;}" in UI_JS + assert "finally{_offlineHealthProbePromise=null;}" in UI_JS + reload_idx = UI_JS.find("window.location.reload()") + assert reload_idx != -1 + assert UI_JS.rfind("_stopOfflineProbeTimer();", 0, reload_idx) != -1 + + +def test_fetch_typeerror_is_gated_by_health_probe_not_blind_banner(): + fetch_patch = UI_JS.split("window.fetch=async function(...args){", 1)[1].split("function initOfflineMonitor", 1)[0] + assert "function _isAbortError(e)" in UI_JS + assert "e instanceof TypeError&&!_isAbortError(e)" in fetch_patch + assert "void _probeOfflineRecovery().then(ok=>{if(!ok)showOfflineBanner('network');})" in fetch_patch + assert "if(!_browserReportsOnline())showOfflineBanner('browser');" in fetch_patch + assert "e instanceof TypeError||!_browserReportsOnline()" not in fetch_patch + + +def test_sse_network_error_defers_to_offline_banner_instead_of_inline_error(): + assert "function _deferStreamErrorIfOffline()" in MESSAGES_JS + assert "t('offline_stream_waiting')" in MESSAGES_JS + assert "if(_deferStreamErrorIfOffline()) return;" in MESSAGES_JS + error_handler = MESSAGES_JS.split("source.addEventListener('error',async e=>{", 1)[1].split("source.addEventListener('cancel'", 1)[0] + assert error_handler.find("_deferStreamErrorIfOffline()") < error_handler.rfind("_handleStreamError()") diff --git a/tests/test_parallel_session_switch.py b/tests/test_parallel_session_switch.py index 69cb6168..566bd19d 100644 --- a/tests/test_parallel_session_switch.py +++ b/tests/test_parallel_session_switch.py @@ -427,10 +427,11 @@ class TestMessagePaginationFrontend: assert "async function _ensureAllMessagesLoaded" in SESSIONS_JS def test_scroll_to_top_triggers_loading(self): - """Scroll event handler must trigger _loadOlderMessages near top.""" + """Scroll event handler must trigger _loadOlderMessages near top when opt-in is enabled.""" UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") - assert "el.scrollTop<80" in UI_JS + assert "const olderPrefetchPx=Math.max(600,el.clientHeight*1.5)" in UI_JS + assert "_isSessionEndlessScrollEnabled()&&el.scrollTop cache.put(event.request, clone))" in src + assert ".catch(() => caches.match(event.request)" in src + assert "if (cached) return cached;" not in src, ( + "shell assets must not be cache-first; stale JS can survive hard refresh" + ) + def test_sw_never_caches_api_responses(self): """Defensive: the SW must not cache responses from /api/* paths. Currently enforced by early-return before the shell-asset cache block.""" @@ -162,6 +179,15 @@ class TestPWARoutes: "the expected scope" ) + def test_sw_is_public_auth_path(self): + src = AUTH.read_text(encoding="utf-8") + public_idx = src.find("PUBLIC_PATHS") + assert public_idx != -1, "auth.py must define PUBLIC_PATHS" + block = src[public_idx:public_idx + 400] + assert "'/sw.js'" in block, ( + "/sw.js must be public so service-worker updates never return login HTML" + ) + class TestIndexHtmlIntegration: def test_index_links_manifest(self): diff --git a/tests/test_session_endless_scroll.py b/tests/test_session_endless_scroll.py new file mode 100644 index 00000000..c075b140 --- /dev/null +++ b/tests/test_session_endless_scroll.py @@ -0,0 +1,32 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8") +BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") +INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8") +PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") +I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8") + + +def test_endless_scroll_is_opt_in_setting(): + assert '"session_endless_scroll": False' in CONFIG_PY + assert '"session_endless_scroll"' in CONFIG_PY + assert 'id="settingsSessionEndlessScroll"' in INDEX_HTML + assert 'data-i18n="settings_label_session_endless_scroll"' in INDEX_HTML + assert 'data-i18n="settings_desc_session_endless_scroll"' in INDEX_HTML + assert "session_endless_scroll: !!($('settingsSessionEndlessScroll')||{}).checked" in PANELS_JS + assert "window._sessionEndlessScrollEnabled=!!s.session_endless_scroll" in BOOT_JS + assert "window._sessionEndlessScrollEnabled=false" in BOOT_JS + + +def test_scroll_listener_prefetches_older_messages_only_when_enabled(): + assert "function _isSessionEndlessScrollEnabled" in UI_JS + assert "const olderPrefetchPx=Math.max(600,el.clientHeight*1.5)" in UI_JS + assert "_isSessionEndlessScrollEnabled()&&el.scrollTop str: + start = src.index(signature) + brace = src.index("{", start) + depth = 0 + for i in range(brace, len(src)): + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + if depth == 0: + return src[start : i + 1] + raise AssertionError(f"function body not found: {signature}") + + +def test_session_jump_buttons_are_opt_in_and_keep_existing_bottom_button(): + assert '"session_jump_buttons": False' in CONFIG_PY + assert '"session_jump_buttons"' in CONFIG_PY + assert "window._sessionJumpButtonsEnabled=!!s.session_jump_buttons" in BOOT_JS + assert "window._sessionJumpButtonsEnabled=false" in BOOT_JS + assert "session_jump_buttons: !!($('settingsSessionJumpButtons')||{}).checked" in PANELS_JS + + scroll_listener = UI_JS[UI_JS.index("el.addEventListener('scroll'") : UI_JS.index("})();", UI_JS.index("el.addEventListener('scroll'"))] + assert "if(btn) btn.style.display=_scrollPinned?'none':'flex'" in scroll_listener + assert "!_isSessionJumpButtonsEnabled()||_scrollPinned" not in UI_JS + + +def test_jump_to_session_start_button_loads_full_history_and_scrolls_top(): + jump = _function_body(UI_JS, "async function jumpToSessionStart") + update = _function_body(UI_JS, "function _updateSessionStartJumpButton") + + assert 'id="jumpToSessionStartBtn"' in INDEX_HTML + assert 'class="session-jump-btn session-jump-btn--start"' in INDEX_HTML + assert "data-i18n=\"session_jump_start\"" in INDEX_HTML + assert "data-i18n=\"session_jump_end\"" in INDEX_HTML + assert "data-i18n-aria-label=\"session_jump_start_label\"" in INDEX_HTML + assert "data-i18n-aria-label=\"session_jump_end_label\"" in INDEX_HTML + + assert "_ensureAllMessagesLoaded" in jump + assert "_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount())" in jump + assert "renderMessages({ preserveScroll:true })" in jump + assert "container.scrollTop=0" in jump + assert "btn.style.display=(hasSession&&canRevealStart&&awayFromStart)?'flex':'none'" in update + + +def test_session_jump_buttons_match_pill_layout_without_regressing_default_arrow(): + assert ".session-jump-btn" in STYLE_CSS + assert ".session-jump-btn--start{top:16px" in STYLE_CSS + assert ".session-jump-btn__text{display:none" in STYLE_CSS + assert ".messages.session-nav-enabled .scroll-to-bottom-btn" in STYLE_CSS + assert ".messages.session-nav-enabled .session-jump-btn__text{display:inline" in STYLE_CSS + assert "classList.toggle('session-nav-enabled',_isSessionJumpButtonsEnabled())" in UI_JS + + +def test_session_jump_buttons_are_i18n_localized_in_text_tooltip_and_aria(): + for key in [ + "session_jump_start", + "session_jump_start_label", + "session_jump_end", + "session_jump_end_label", + "settings_label_session_jump_buttons", + "settings_desc_session_jump_buttons", + ]: + assert I18N_JS.count(f"{key}:") >= 8, f"missing locale entries for {key}" + assert "document.querySelectorAll('[data-i18n-aria-label]')" in I18N_JS + assert "el.setAttribute('aria-label', val)" in I18N_JS