From a091be6a8e43b24663f7eefb3e7f1c453590659f Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 27 Apr 2026 16:27:03 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20batch=20v0.50.229=20=E2=80=94=20session?= =?UTF-8?q?=20perf,=20ephemeral=20sessions,=20iOS=20zoom=20(#1183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged as v0.50.229. 2678 tests passing. Browser QA 21/21. All three PRs were independently reviewed and approved by @nesquena with reviewer commits pulled in: - #1181 (#1158): `d974388` (stale-response race in _loadOlderMessages) - #1182: `7e20006` (full-scan fallback path consistency) - #1180: `a5ad154` (regression test for iOS zoom threshold) Thanks @jasonjcwu (#1158)! --- CHANGELOG.md | 39 ++ api/models.py | 14 +- api/routes.py | 51 ++- api/workspace.py | 38 +- static/boot.js | 14 + static/commands.js | 5 +- static/i18n.js | 8 + static/messages.js | 2 +- static/sessions.js | 97 ++++- static/style.css | 13 + static/ui.js | 13 + static/workspace.js | 19 +- tests/test_issue789.py | 53 +-- tests/test_mobile_layout.py | 24 ++ tests/test_parallel_session_switch.py | 559 ++++++++++++++++++++++++++ tests/test_sprint37.py | 15 +- 16 files changed, 903 insertions(+), 61 deletions(-) create mode 100644 tests/test_parallel_session_switch.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c96e6153..936be3d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -357,6 +357,45 @@ workspace subtree) and never enumerate blocked system roots. (`api/routes.py`, `api/workspace.py`, `static/panels.js`, `static/style.css`) (partial for #616) +## [v0.50.229] — 2026-04-27 + +### Performance +- **Session switch parallelization** — directory pre-fetches use `Promise.all()` (N×RTT → 1×RTT); + git status/ahead/behind run in parallel via `ThreadPoolExecutor(max_workers=3)`; + `loadDir()` and `highlightCode()` overlap on the idle path. + (`api/workspace.py`, `static/sessions.js`, `static/workspace.js`) (#1158, @jasonjcwu) + +### Fixed +- **Message pagination for long conversations** — sessions with more than 30 messages load the + most-recent 30 on switch; older messages load on scroll-to-top or the "↑ load older" indicator. + Stale-response race in `_loadOlderMessages` closed; all undo/retry/compress/done paths reset + pagination state. (`api/routes.py`, `static/sessions.js`, `static/ui.js`, `static/commands.js`, + `static/i18n.js`) (#1158, @jasonjcwu) +- **Ephemeral untitled sessions never appear in sidebar** — empty Untitled sessions are now + suppressed immediately rather than surfacing for 60 seconds. Both the index-path and full-scan + fallback filters are consistent; boot path skips restoring a zero-message session from storage. + (`api/models.py`, `static/boot.js`, `static/sessions.js`) (#1182) +- **iOS Safari auto-zoom on input focus** — inputs, textareas, and selects on touch devices now + have a minimum `font-size: max(16px, 1em)` via `@media (hover:none) and (pointer:coarse)`, + preventing iOS from zooming in on focus. Accessibility-safe: user's OS font preference is + respected when it exceeds 16px. (`static/style.css`) (#1167, #1180) + +## [v0.50.229] — 2026-04-27 + +### Performance +- **Session switch parallelization** — directory pre-fetches now use `Promise.all()` (N×RTT → 1×RTT); + git status/ahead/behind subprocesses run in parallel via `ThreadPoolExecutor(max_workers=3)`; + `loadDir()` and `highlightCode()` run concurrently on idle path. Session switches with expanded + workspace dirs are measurably faster on high-latency connections. + (`api/workspace.py`, `static/sessions.js`, `static/workspace.js`) (#1158, @jasonjcwu) + +### Added +- **Message pagination for long conversations** — sessions with more than 30 messages now load + the most-recent 30 on switch; older messages load on scroll-to-top or via the "↑ load older" + indicator at the top of the message list. All undo/retry/compression paths reset pagination + state correctly. (`api/routes.py`, `static/sessions.js`, `static/ui.js`, `static/commands.js`) + (#1158, @jasonjcwu) + ## [v0.50.228] — 2026-04-27 ### Fixed diff --git a/api/models.py b/api/models.py index f44fa67c..a8aa015e 100644 --- a/api/models.py +++ b/api/models.py @@ -556,13 +556,14 @@ def all_sessions(): active_stream_ids=active_stream_ids, ) result = sorted(index_map.values(), key=lambda s: (s.get('pinned', False), _session_sort_timestamp(s)), reverse=True) - # Hide empty Untitled sessions from the UI (created by tests, page refreshes, etc.) - # Exempt sessions younger than 60 s so a brand-new session stays visible (#789) - _now = time.time() + # Hide empty Untitled sessions from the UI entirely — they are ephemeral + # scratch pads that only become real once the first message is sent (#1171). + # No grace window: a 0-message Untitled session is never shown in the list + # regardless of age. This means page refreshes and accidental New Conversation + # clicks never leave orphan entries in the sidebar. result = [s for s in result if not ( s.get('title', 'Untitled') == 'Untitled' and s.get('message_count', 0) == 0 - and (_now - s.get('updated_at', _now)) > 60 )] result = [s for s in result if not _hide_from_default_sidebar(s)] # Backfill: sessions created before Sprint 22 have no profile tag. @@ -585,11 +586,12 @@ def all_sessions(): for s in SESSIONS.values(): if all(s.session_id != x.session_id for x in out): out.append(s) out.sort(key=lambda s: (getattr(s, 'pinned', False), _session_sort_timestamp(s)), reverse=True) - _now = time.time() + # Hide empty Untitled sessions from the UI entirely — kept consistent with the + # index-path filter above. No grace window: a 0-message Untitled session is + # never shown regardless of age (#1171). result = [s.compact(include_runtime=True, active_stream_ids=active_stream_ids) for s in out if not ( s.title == 'Untitled' and len(s.messages) == 0 - and (_now - s.updated_at) > 60 )] result = [s for s in result if not _hide_from_default_sidebar(s)] for s in result: diff --git a/api/routes.py b/api/routes.py index d9a1dde0..ea2f7135 100644 --- a/api/routes.py +++ b/api/routes.py @@ -772,6 +772,23 @@ def handle_get(handler, parsed) -> bool: load_messages = query.get("messages", ["1"])[0] != "0" resolve_model_default = "1" if load_messages else "0" resolve_model = query.get("resolve_model", [resolve_model_default])[0] != "0" + # ?msg_limit=N returns only the last N messages (tail window). + # Used by the frontend for fast session switching — avoids serialising + # and sending hundreds of messages when the user only sees the most + # recent exchange. Older messages are loaded on-demand via scrolling. + _msg_limit = query.get("msg_limit", [None])[0] + try: + msg_limit = max(1, int(_msg_limit)) if _msg_limit else None + except (ValueError, TypeError): + msg_limit = None + # ?msg_before=N — 0-based index into the full message array. + # Returns messages before this index (for scroll-to-top lazy loading). + # Combined with msg_limit for paging. + _msg_before = query.get("msg_before", [None])[0] + try: + msg_before = int(_msg_before) if _msg_before else None + except (ValueError, TypeError): + msg_before = None try: _t1 = _time.monotonic() s = get_session(sid, metadata_only=(not load_messages)) @@ -782,14 +799,46 @@ def handle_get(handler, parsed) -> bool: else None ) _t3 = _time.monotonic() + _all_msgs = s.messages if load_messages else [] + if load_messages: + if msg_before is not None: + # Scroll-to-top paging: msg_before is a 0-based index into + # the full message list. Return the msg_limit messages that + # appear *before* this index (i.e. older messages). + # Using index instead of timestamp avoids issues with + # duplicate/missing timestamps. + _before_idx = max(0, min(int(msg_before), len(_all_msgs))) + _slice = _all_msgs[:_before_idx] + _truncated_msgs = _slice[-msg_limit:] if msg_limit else _slice + elif msg_limit and len(_all_msgs) > msg_limit: + _truncated_msgs = _all_msgs[-msg_limit:] + else: + _truncated_msgs = _all_msgs + else: + _truncated_msgs = _all_msgs raw = s.compact() | { - "messages": s.messages if load_messages else [], + "messages": _truncated_msgs, "tool_calls": getattr(s, "tool_calls", []) if load_messages else [], "active_stream_id": getattr(s, "active_stream_id", None), "pending_user_message": getattr(s, "pending_user_message", None), "pending_attachments": getattr(s, "pending_attachments", []) if load_messages else [], "pending_started_at": getattr(s, "pending_started_at", None), } + # Signal to the frontend that older messages were omitted. + # For msg_before paging, compare against the filtered set, + # not the full list — otherwise we signal truncation even when + # all older messages were returned. + if msg_before is not None: + _truncated = load_messages and msg_limit is not None and len(_slice) > msg_limit + else: + _truncated = load_messages and msg_limit is not None and len(_all_msgs) > msg_limit + raw["_messages_truncated"] = _truncated + # Index of the first returned message in the full message array. + # Frontend uses this as cursor for scroll-to-top paging. + if msg_before is not None: + raw["_messages_offset"] = max(0, _before_idx - len(_truncated_msgs)) + else: + raw["_messages_offset"] = max(0, len(_all_msgs) - len(_truncated_msgs)) _t4 = _time.monotonic() if effective_model: raw["model"] = effective_model diff --git a/api/workspace.py b/api/workspace.py index 53e9ff49..d7d4b4fe 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -11,6 +11,7 @@ import json import logging import os import subprocess +import concurrent.futures from pathlib import Path logger = logging.getLogger(__name__) @@ -606,22 +607,35 @@ def git_info_for_workspace(workspace: Path) -> dict: branch = _run_git(['rev-parse', '--abbrev-ref', 'HEAD'], workspace) if branch is None: return None - # Status counts - status_out = _run_git(['status', '--porcelain'], workspace) or '' - lines = [l for l in status_out.splitlines() if l] - # git status --porcelain: XY format where X=index, Y=worktree - modified = sum(1 for l in lines if len(l) >= 2 and (l[0] in 'MAR' or l[1] in 'MAR')) - untracked = sum(1 for l in lines if l.startswith('??')) - dirty = len(lines) - # Ahead/behind - ahead = _run_git(['rev-list', '--count', '@{u}..HEAD'], workspace) - behind = _run_git(['rev-list', '--count', 'HEAD..@{u}'], workspace) + # Run the remaining git commands in parallel via threads — they are + # independent subprocess calls and together can take 50-200ms when run + # serially. Threading is safe here because each call blocks only on the + # subprocess pipe, not on the GIL. + def _ahead(): + r = _run_git(['rev-list', '--count', '@{u}..HEAD'], workspace) + return int(r) if r and r.isdigit() else 0 + def _behind(): + r = _run_git(['rev-list', '--count', 'HEAD..@{u}'], workspace) + return int(r) if r and r.isdigit() else 0 + def _status(): + out = _run_git(['status', '--porcelain'], workspace) or '' + lines = [l for l in out.splitlines() if l] + modified = sum(1 for l in lines if len(l) >= 2 and (l[0] in 'MAR' or l[1] in 'MAR')) + untracked = sum(1 for l in lines if l.startswith('??')) + return len(lines), modified, untracked + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool: + f_status = pool.submit(_status) + f_ahead = pool.submit(_ahead) + f_behind = pool.submit(_behind) + dirty, modified, untracked = f_status.result() + ahead = f_ahead.result() + behind = f_behind.result() return { 'branch': branch, 'dirty': dirty, 'modified': modified, 'untracked': untracked, - 'ahead': int(ahead) if ahead and ahead.isdigit() else 0, - 'behind': int(behind) if behind and behind.isdigit() else 0, + 'ahead': ahead, + 'behind': behind, 'is_git': True, } diff --git a/static/boot.js b/static/boot.js index 09bf265f..e59a8eb8 100644 --- a/static/boot.js +++ b/static/boot.js @@ -891,6 +891,20 @@ function applyBotName(){ if(saved){ try{ await loadSession(saved); + // If the restored session has no messages it is an ephemeral scratch pad — + // treat the page as a fresh start rather than resuming a blank conversation. + // The session stays on disk (it will be cleaned up later) but we don't surface + // it or lock the user into it. Clear the stored ID so a true new session is + // created the first time the user hits + or sends a message (#1171). + if(S.session && (S.session.message_count||0) === 0){ + localStorage.removeItem('hermes-webui-session'); + S.session=null; S.messages=[]; + S._bootReady=true; + syncTopbar();syncWorkspacePanelState(); + $('emptyState').style.display=''; + await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE(); + return; + } // Restore the panel from localStorage when the session has a workspace. // Preference key takes priority over runtime state so that closing // the panel via toolbar X doesn't suppress the "keep open" setting. diff --git a/static/commands.js b/static/commands.js index 97e4c72b..07d20fc0 100644 --- a/static/commands.js +++ b/static/commands.js @@ -284,6 +284,7 @@ async function _runManualCompression(focusTopic){ S.session=live.session; S.messages=live.session.messages||[]; S.toolCalls=live.session.tool_calls||[]; + if(typeof _messagesTruncated!=='undefined') _messagesTruncated=false; }catch(preflightErr){ if(typeof clearCompressionUi==='function') clearCompressionUi(); if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null); @@ -682,7 +683,7 @@ async function cmdRetry(){ if(r&&r.error){showToast(r.error);return;} if(!S.session||S.session.session_id!==activeSid)return; const data=await api('/api/session?session_id='+encodeURIComponent(activeSid)); - if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();renderMessages();} + if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=false;renderMessages();} $('msg').value=r.last_user_text||'';if(typeof autoResize==='function')autoResize();await send(); }catch(e){showToast(t('retry_failed')+e.message);} } @@ -695,7 +696,7 @@ async function cmdUndo(){ if(r&&r.error){showToast(r.error);return;} if(!S.session||S.session.session_id!==activeSid)return; const data=await api('/api/session?session_id='+encodeURIComponent(activeSid)); - if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();renderMessages();} + if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=false;renderMessages();} showToast(`↩ ${t('undid_n_messages')} ${r.removed_count} ${t('undid_messages_suffix')}`); }catch(e){showToast(t('undo_failed')+e.message);} } diff --git a/static/i18n.js b/static/i18n.js index b05c2ab7..fd468c25 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -53,6 +53,7 @@ const LOCALES = { clarify_responding: 'Responding\u2026', untitled: 'Untitled', n_messages: (n) => `${n} messages`, + load_older_messages: '↑ Scroll up or click to load older messages', queued_label: 'Sends after response', queued_count: (n) => n === 1 ? '1 queued' : `${n} queued`, queued_cancel: 'Cancel queued message', @@ -705,6 +706,7 @@ const LOCALES = { approval_responding: 'Отвечаю…', untitled: 'Без названия', n_messages: (n) => `${n} сообщений`, + load_older_messages: '↑ Прокрутите вверх или нажмите, чтобы загрузить ранние сообщения', queued_label: 'Отправить после ответа', queued_count: (n) => n === 1 ? '1 в очереди' : `${n} в очереди`, queued_cancel: 'Отменить сообщение', @@ -1321,6 +1323,7 @@ const LOCALES = { clarify_responding: 'Respondiendo…', untitled: 'Sin título', n_messages: (n) => `${n} mensajes`, + load_older_messages: '↑ Desplázate hacia arriba o haz clic para cargar mensajes anteriores', 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', @@ -1922,6 +1925,7 @@ const LOCALES = { clarify_responding: 'Antwortet\u2026', untitled: 'Unbenannt', n_messages: (n) => `${n} Nachrichten`, + load_older_messages: '↑ Nach oben scrollen oder klicken, um ältere Nachrichten zu laden', queued_label: 'Wird nach Antwort gesendet', queued_count: (n) => n === 1 ? '1 in Warteschlange' : `${n} in Warteschlange`, queued_cancel: 'Nachricht abbrechen', @@ -2297,6 +2301,7 @@ const LOCALES = { clarify_responding: '处理中…', untitled: '\u672a\u547d\u540d', n_messages: (n) => `${n} \u6761\u6d88\u606f`, + load_older_messages: '↑ 向上滚动或点击加载更早的消息', queued_label: '响应后发送', queued_count: (n) => n === 1 ? '1 条排队' : `${n} 条排队`, queued_cancel: '取消排队消息', @@ -2896,6 +2901,7 @@ const LOCALES = { clarify_responding: '\u8655\u7406\u4e2d\u2026', untitled: '\u672a\u547d\u540d', n_messages: (n) => `${n} \u689d\u8a0a\u606f`, + load_older_messages: '↑ 向上捲動或點擊以載入較早的訊息', 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`, @@ -3159,6 +3165,7 @@ const LOCALES = { delete_confirm: (name) => `刪除「${name}」?`, downloading: (filename) => `正在下載 ${filename}…`, n_messages: (n) => `${n} 則訊息`, + load_older_messages: '↑ 向上捲動或點擊以載入較早的訊息', 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', @@ -3542,6 +3549,7 @@ const LOCALES = { clarify_responding: '응답 중\u2026', untitled: '제목 없음', n_messages: (n) => `${n}개 메시지`, + load_older_messages: '↑ 위로 스크롤하거나 클릭하여 이전 메시지 불러오기', queued_label: 'Sends after response', queued_count: (n) => n === 1 ? '1 queued' : `${n} queued`, queued_cancel: 'Cancel queued message', diff --git a/static/messages.js b/static/messages.js index 07067b8f..b8f02101 100644 --- a/static/messages.js +++ b/static/messages.js @@ -759,7 +759,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const _prevIn=(S.session&&S.session.input_tokens)||0; const _prevOut=(S.session&&S.session.output_tokens)||0; const _prevCost=(S.session&&S.session.estimated_cost)||0; - S.session=d.session;S.messages=d.session.messages||[]; + S.session=d.session;S.messages=d.session.messages||[];if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!d.session._messages_truncated; if( window._compressionUi&&window._compressionUi.automatic&& window._compressionUi.sessionId===activeSid&& diff --git a/static/sessions.js b/static/sessions.js index 0d504393..6a1d55aa 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -117,6 +117,9 @@ async function loadSession(sid){ if (currentSid !== sid) { S.messages = []; S.toolCalls = []; + _messagesTruncated = false; + _oldestIdx = 0; + _loadingOlder = false; const _msgInner = $('msgInner'); if (_msgInner) _msgInner.innerHTML = '
Loading conversation...
'; } @@ -263,7 +266,12 @@ async function loadSession(sid){ setStatus(''); setComposerStatus(''); updateQueueBadge(sid); - syncTopbar();renderMessages();highlightCode();loadDir('.'); + syncTopbar();renderMessages(); + // Kick off loadDir first (issues network requests), then highlight code. + // The fetch is dispatched before the CPU-bound Prism pass begins. + const _dirP=loadDir('.'); + highlightCode(); + await _dirP; } } @@ -302,17 +310,28 @@ function _resolveSessionModelForDisplaySoon(sid){ },0); } +// Tracks whether the current session has older messages that were not +// loaded during the initial paginated fetch (msg_limit window). +// When true, scrolling to the top triggers _loadOlderMessages(). +let _messagesTruncated = false; + // Load session messages if not already present. // Called after loadSession fetches metadata (messages=0). // Idempotent: if messages are already in S.messages, resolves immediately. // Handles streaming sessions specially: restores from INFLIGHT cache or API. +// msg_limit (default 30): only fetch the last N messages for fast switching. +// Older messages are loaded on-demand via _loadOlderMessages(). +const _INITIAL_MSG_LIMIT = 30; + async function _ensureMessagesLoaded(sid) { // Already have messages? (e.g. from INFLIGHT restore path, already set) if (S.messages && S.messages.length > 0 && S.messages[0] && S.messages[0].role) { return; } - // Fetch full session with messages - const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0`); + // Fetch session messages with a tail window for fast initial load. + const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_limit=${_INITIAL_MSG_LIMIT}`); + _messagesTruncated = !!data.session._messages_truncated; + _oldestIdx = data.session._messages_offset || 0; const msgs = (data.session.messages || []).filter(m => m && m.role); // Check for tool-call metadata on messages (for tool-call card rendering) const hasMessageToolMetadata = msgs.some(m => { @@ -335,6 +354,69 @@ async function _ensureMessagesLoaded(sid) { } } +// Load older messages when the user scrolls to the top of the conversation. +// Prepends them to S.messages and re-renders, preserving scroll position. +let _loadingOlder = false; +// _oldestIdx tracks the index (in the server's full message array) of the +// oldest message currently loaded in S.messages. Starts at 0 when all +// messages are loaded, or > 0 when truncated by msg_limit. +let _oldestIdx = 0; + +async function _loadOlderMessages() { + if (_loadingOlder || !_messagesTruncated) return; + const sid = S.session ? S.session.session_id : null; + if (!sid || !S.messages.length) return; + if (_oldestIdx <= 0) { _messagesTruncated = false; return; } + _loadingOlder = true; + try { + const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_before=${_oldestIdx}&msg_limit=${_INITIAL_MSG_LIMIT}`); + // Cancellation guards: + // - response shape sane + // - the active session is still the one we issued the request for. + // Compare against S.session.session_id, NOT _loadingSessionId — the + // latter is null between session loads, leaving a window where a + // stale response could prepend onto the new session's S.messages. + if (!data || !data.session) return; + if (!S.session || S.session.session_id !== sid) return; + if (_loadingSessionId !== null && _loadingSessionId !== sid) return; + const olderMsgs = (data.session.messages || []).filter(m => m && m.role); + if (!olderMsgs.length) { _messagesTruncated = false; return; } + // Prepend older messages + const inner = $('msgInner'); + const prevScrollH = inner ? inner.scrollHeight : 0; + S.messages = [...olderMsgs, ...S.messages]; + _messagesTruncated = !!data.session._messages_truncated; + _oldestIdx = data.session._messages_offset || 0; + renderMessages(); + // Restore scroll position so the user stays at the same message + if (inner) { + const newScrollH = inner.scrollHeight; + inner.scrollTop = newScrollH - prevScrollH; + } + } catch(e) { + console.warn('_loadOlderMessages failed:', e); + } finally { + // Always clear the loading lock. If the user switched sessions while + // this request was in flight, loadSession() already set _loadingOlder=false + // (see line ~122), so this is a harmless double-reset. + _loadingOlder = false; + } +} + +// Ensure the full message history is loaded (for undo, export, etc). +// If the session was loaded with msg_limit, this fetches all messages. +async function _ensureAllMessagesLoaded() { + if (!_messagesTruncated || !S.session) return; + const sid = S.session.session_id; + const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0`); + const msgs = (data.session.messages || []).filter(m => m && m.role); + S.messages = msgs; + _messagesTruncated = false; + if(S.session && S.session.session_id === sid){ + S.session.message_count = Number(data.session.message_count || msgs.length); + } +} + let _allSessions = []; // cached for search filter let _renamingSid = null; // session_id currently being renamed (blocks list re-renders) let _showArchived = false; // toggle to show archived sessions @@ -746,10 +828,15 @@ function renderSessionListFromCache(){ // Merge content matches (deduped): content matches appended after title matches const titleIds=new Set(titleMatches.map(s=>s.session_id)); const allMatched=q?[...titleMatches,..._contentSearchResults.filter(s=>!titleIds.has(s.session_id))]:titleMatches; + // Never surface ephemeral 0-message sessions in the sidebar — they only become + // real once the first message is sent. The server already filters them, but this + // guard ensures a brand-new active session doesn't flash into the list while + // _allSessions is stale from a prior render (#1171). + const withMessages=allMatched.filter(s=>(s.message_count||0)>0 || (S.session&&s.session_id===S.session.session_id&&(S.session.message_count||0)>0)); // Filter by active profile (unless "All profiles" is toggled on) // Server backfills profile='default' for legacy sessions, so every session has a profile. // Show only sessions tagged to the active profile; 'All profiles' toggle overrides. - const profileFiltered=_showAllProfiles?allMatched:allMatched.filter(s=>s.is_cli_session||s.profile===S.activeProfile); + const profileFiltered=_showAllProfiles?withMessages:withMessages.filter(s=>s.is_cli_session||s.profile===S.activeProfile); // Filter by active project const projectFiltered=_activeProject?profileFiltered.filter(s=>s.project_id===_activeProject):profileFiltered; // Filter archived unless toggle is on @@ -798,7 +885,7 @@ function renderSessionListFromCache(){ list.appendChild(bar); } // Profile filter toggle (show sessions from other profiles) - const otherProfileCount=allMatched.filter(s=>s.profile&&s.profile!==S.activeProfile).length; + const otherProfileCount=withMessages.filter(s=>s.profile&&s.profile!==S.activeProfile).length; if(otherProfileCount>0&&!_showAllProfiles){ const pfToggle=document.createElement('div'); pfToggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;'; diff --git a/static/style.css b/static/style.css index d8e4c9c5..578a1706 100644 --- a/static/style.css +++ b/static/style.css @@ -2184,6 +2184,8 @@ main.main.showing-profiles > #mainProfiles{display:flex;} .assistant-segment[data-error="1"] .role-icon { background: rgba(239,83,80,.12); color: var(--error); border-color: var(--error); } /* ── Day-change separator ── */ +.load-older-indicator{cursor:pointer;color:var(--accent-text,var(--blue));font-size:13px;padding:10px 0;text-align:center;user-select:none;} +.load-older-indicator:hover{opacity:.75;} .msg-date-sep { display: flex; align-items: center; gap: 10px; margin: 22px 0 10px; padding: 0 var(--msg-rail); @@ -2353,3 +2355,14 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view- .queue-pill:hover{background:color-mix(in srgb,var(--hover-bg) 60%,var(--surface));} .queue-pill-count{color:var(--text);} .queue-pill-chevron{margin-left:auto;opacity:.5;} + +/* ── Mobile: prevent iOS auto-zoom on input focus ───────────────────────── + iOS Safari zooms in when a focused input/select has font-size < 16px. + This media query targets touch-primary devices (phones and tablets) and + bumps all interactive inputs to the minimum non-zoom threshold. + The textarea#msg composer is already 16px; the sidebar search, rename + inputs, settings selects, and dialog inputs are not. (#1167) +──────────────────────────────────────────────────────────────────────── */ +@media (hover:none) and (pointer:coarse){ + input,textarea,select{font-size:max(16px,1em)!important;} +} diff --git a/static/ui.js b/static/ui.js index e8a81512..f1642ed9 100644 --- a/static/ui.js +++ b/static/ui.js @@ -589,6 +589,10 @@ let _scrollPinned=true; _scrollPinned=nearBottom; 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'){ + _loadOlderMessages(); + } }); })(); function _fmtTokens(n){if(!n||n<0)return'0';if(n>=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'k';return String(n);} @@ -2245,6 +2249,15 @@ function renderMessages(){ }); $('emptyState').style.display=(vis.length||preservedCompressionTaskMessages.length)?'none':''; inner.innerHTML=''; + // Show "load older" indicator when older messages are available + if(typeof _messagesTruncated!=='undefined' && _messagesTruncated && S.messages.length>0){ + const indicator=document.createElement('div'); + indicator.id='loadOlderIndicator'; + indicator.className='load-older-indicator'; + indicator.textContent=typeof t==='function'?t('load_older_messages'):'↑ Scroll up or click to load older messages'; + indicator.onclick=()=>{if(typeof _loadOlderMessages==='function') _loadOlderMessages();}; + inner.appendChild(indicator); + } const compressionNode=compressionState?_compressionCardsNode(compressionState):null; const referenceMessage=S.messages.find(m=>_isContextCompactionMessage(m)); const referenceText=referenceMessage?msgContent(referenceMessage)||String(referenceMessage.content||''):''; diff --git a/static/workspace.js b/static/workspace.js index 4965ce4e..491edec8 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -62,16 +62,19 @@ async function loadDir(path){ const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`); S.entries=data.entries||[];renderBreadcrumb();renderFileTree(); // Pre-fetch contents of restored expanded dirs so they render without a second click + // (parallelized — avoids serial waterfall when multiple dirs are expanded) if(!path||path==='.'){ - for(const dirPath of (S._expandedDirs||[])){ - if(!S._dirCache[dirPath]){ - try{ - const dc=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(dirPath)}`); - S._dirCache[dirPath]=dc.entries||[]; - }catch(e2){S._dirCache[dirPath]=[];} - } + const expanded=S._expandedDirs||new Set(); + const pending=[...expanded].filter(dirPath=>!S._dirCache[dirPath]); + if(pending.length){ + const results=await Promise.all(pending.map(dirPath=> + api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(dirPath)}`) + .then(dc=>({dirPath,entries:dc.entries||[]})) + .catch(()=>({dirPath,entries:[]})) + )); + for(const {dirPath,entries} of results) S._dirCache[dirPath]=entries; } - if(S._expandedDirs&&S._expandedDirs.size>0)renderFileTree(); + if(expanded.size>0)renderFileTree(); } if(typeof clearPreview==='function'){ if(typeof _previewDirty!=='undefined'&&_previewDirty){ diff --git a/tests/test_issue789.py b/tests/test_issue789.py index 5c45e7fc..a7846790 100644 --- a/tests/test_issue789.py +++ b/tests/test_issue789.py @@ -1,14 +1,17 @@ """ Regression tests for GitHub issue #789. -Bug: every brand-new session immediately disappeared from the sidebar because -all_sessions() filtered out sessions where title == 'Untitled' AND -message_count == 0. Since every new session starts with those values, it was -filtered out of /api/sessions on the next refresh. +Original bug (#789): new sessions immediately disappeared because all_sessions() +filtered out Untitled + 0-message sessions. -Fix: exempt sessions younger than 60 seconds from that filter. Sessions older -than 60 seconds that are still Untitled with 0 messages are still suppressed -(ghost sessions from test runs / accidental reloads). +Original fix: exempt sessions younger than 60 seconds. + +Updated for #1171 / #1182: a session only "exists" from the user's perspective +once the first message is sent. Untitled + 0-message sessions are now hidden +from the sidebar **regardless of age** — no grace window. The button guard +(#1176) and the boot-restore guard (#1182) ensure the user is never locked +out of typing into a fresh session, but the sidebar list never surfaces empty +ones. These tests reflect the new contract. """ import json import time @@ -67,34 +70,37 @@ def _make_titled_session(age_seconds, session_id=None): return s -# ── Test 1: brand-new Untitled 0-message session IS included ───────────────── +# ── Test 1: Untitled 0-message sessions are hidden regardless of age (#1171) ─ -def test_new_untitled_session_is_visible_in_sidebar(): - """A session created just now (0 seconds old) must appear in all_sessions().""" +def test_new_untitled_session_is_hidden_from_sidebar(): + """A brand-new (0 s old) Untitled 0-message session must NOT appear (#1171). + + Updated for #1171/#1182: sessions only "exist" once the first message is + sent. Empty scratch-pad sessions never surface in the sidebar. + """ new_session = _make_untitled_session(age_seconds=0) result = all_sessions() ids = {s["session_id"] for s in result} - assert new_session.session_id in ids, ( - "Brand-new Untitled 0-message session must be visible in the sidebar " - "(fix for issue #789)" + assert new_session.session_id not in ids, ( + "Untitled 0-message session must be hidden regardless of age (#1171)" ) -def test_recent_untitled_session_under_60s_is_visible(): - """A session 30 seconds old should still be visible.""" +def test_recent_untitled_session_under_60s_is_hidden(): + """A 30-second-old empty session must also be hidden (no grace window).""" recent_session = _make_untitled_session(age_seconds=30) result = all_sessions() ids = {s["session_id"] for s in result} - assert recent_session.session_id in ids, ( - "Untitled 0-message session younger than 60 s must be visible (#789)" + assert recent_session.session_id not in ids, ( + "Untitled 0-message session younger than 60 s is also hidden (#1171)" ) -# ── Test 2: old Untitled 0-message session IS still filtered ───────────────── +# ── Test 2: old Untitled 0-message session is still filtered ───────────────── def test_old_untitled_session_over_60s_is_filtered(): """A ghost session (Untitled, 0 messages, >60 s old) must be hidden.""" @@ -109,14 +115,14 @@ def test_old_untitled_session_over_60s_is_filtered(): def test_session_exactly_at_boundary_is_filtered(): - """A session just over 60 seconds old should be filtered.""" + """A session at any age (the previous 60 s threshold no longer applies).""" boundary_session = _make_untitled_session(age_seconds=61) result = all_sessions() ids = {s["session_id"] for s in result} assert boundary_session.session_id not in ids, ( - "Untitled 0-message session older than 60 s must be filtered out" + "Untitled 0-message session is filtered regardless of age (#1171)" ) @@ -181,7 +187,8 @@ def test_titled_session_with_no_messages_old_is_visible(): # ── Test 4: mixed bag — only old Untitled empty sessions are filtered ───────── def test_mixed_sessions_correct_visibility(): - """With a mix of sessions, only old+Untitled+empty ones are suppressed.""" + """With a mix of sessions, only sessions with messages OR titled sessions + are surfaced (#1171). Both new and old Untitled+empty sessions are hidden.""" new_ghost = _make_untitled_session(age_seconds=5, session_id="new_ghost") old_ghost = _make_untitled_session(age_seconds=200, session_id="old_ghost") real_session = _make_titled_session(age_seconds=500, session_id="real_session") @@ -189,6 +196,6 @@ def test_mixed_sessions_correct_visibility(): result = all_sessions() ids = {s["session_id"] for s in result} - assert "new_ghost" in ids, "New Untitled session (5s old) must be visible" - assert "old_ghost" not in ids, "Old Untitled session (200s old) must be hidden" + assert "new_ghost" not in ids, "New Untitled empty session is also hidden (#1171)" + assert "old_ghost" not in ids, "Old Untitled session must be hidden" assert "real_session" in ids, "Titled session with messages must be visible" diff --git a/tests/test_mobile_layout.py b/tests/test_mobile_layout.py index 1aebb507..6d687a7c 100644 --- a/tests/test_mobile_layout.py +++ b/tests/test_mobile_layout.py @@ -232,6 +232,30 @@ def test_composer_textarea_font_size_mobile(): "Composer textarea must have font-size:16px at mobile widths to prevent iOS zoom-on-focus" +def test_touch_device_inputs_meet_zoom_threshold(): + """All input/textarea/select must clear iOS Safari's 16px zoom threshold + on touch-primary devices, not just the composer textarea (#1167). + + This locks the global media-query floor so future per-element font-size + tweaks (sidebar search 13px, settings selects 12px, dialog inputs 14px, + onboarding fields 13px) cannot accidentally re-introduce auto-zoom. + """ + # The hover:none + pointer:coarse pair is the canonical touch-primary + # detection (won't match desktop with mouse, won't match touch laptops + # that report hover:hover). + pattern = re.compile( + r'@media\s*\(hover:none\)\s*and\s*\(pointer:coarse\)\s*\{[^}]*' + r'input\s*,\s*textarea\s*,\s*select\s*\{[^}]*' + r'font-size:\s*max\(\s*16px', + re.DOTALL, + ) + assert pattern.search(CSS), ( + "style.css must contain a (hover:none) and (pointer:coarse) media " + "query that bumps input/textarea/select to font-size:max(16px,…) " + "so iOS Safari does not auto-zoom on focus (#1167)" + ) + + # ── Sidebar tabs on mobile ─────────────────────────────────────────────────── diff --git a/tests/test_parallel_session_switch.py b/tests/test_parallel_session_switch.py new file mode 100644 index 00000000..f8f1828e --- /dev/null +++ b/tests/test_parallel_session_switch.py @@ -0,0 +1,559 @@ +"""Tests for session-switch performance optimizations. + +Four optimizations to reduce session-switch latency: + +1. loadDir expanded-dir pre-fetch uses Promise.all (workspace.js) +2. loadSession idle path overlaps loadDir with highlightCode (sessions.js) +3. git_info_for_workspace runs git subprocesses in parallel (workspace.py) +4. Message pagination: msg_limit tail-window + msg_before index cursor (routes.py + sessions.js) +""" + +import pathlib +import threading +import time +from unittest.mock import patch, MagicMock + +REPO = pathlib.Path(__file__).parent.parent +SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") +WORKSPACE_JS = (REPO / "static" / "workspace.js").read_text(encoding="utf-8") +ROUTES_PY = (REPO / "api" / "routes.py").read_text(encoding="utf-8") + + +# ── 1. workspace.js: expanded-dir pre-fetch is parallelized ───────────────── + + +class TestLoadDirParallelPrefetch: + """The expanded-dir pre-fetch inside loadDir() must use Promise.all() + instead of a serial for-await loop to avoid N sequential roundtrips.""" + + def test_loaddir_uses_promise_all_for_expanded_dirs(self): + marker = "Pre-fetch contents of restored expanded dirs" + idx = WORKSPACE_JS.find(marker) + assert idx >= 0, "Expanded-dir pre-fetch comment not found in workspace.js" + + block = WORKSPACE_JS[idx : idx + 800] + assert "Promise.all" in block, ( + "loadDir expanded-dir pre-fetch should use Promise.all() for " + "parallel fetching, not a serial for-await loop." + ) + + def test_loaddir_no_serial_for_await_in_prefetch(self): + marker = "Pre-fetch contents of restored expanded dirs" + idx = WORKSPACE_JS.find(marker) + assert idx >= 0 + block = WORKSPACE_JS[idx : idx + 800] + assert "for(const dirPath of (S._expandedDirs" not in block, ( + "loadDir still has a serial for-await loop for expanded dirs — " + "should use Promise.all with .map() instead." + ) + + def test_expanded_dirs_fallback_is_set(self): + """S._expandedDirs||fallback must produce a Set, not an Array.""" + marker = "Pre-fetch contents of restored expanded dirs" + idx = WORKSPACE_JS.find(marker) + assert idx >= 0 + block = WORKSPACE_JS[idx : idx + 800] + assert "S._expandedDirs||new Set()" in block, ( + "Expanded dirs fallback must be 'new Set()' not '[]' — " + "arrays have no .size property." + ) + + +# ── 2. sessions.js: loadSession idle path overlaps loadDir and highlightCode ─ + + +class TestLoadSessionIdleOverlap: + """The idle path in loadSession() must start loadDir() before running + highlightCode() so the network request is in-flight during the CPU-bound + Prism.js pass.""" + + def test_idle_path_starts_loaddir_before_highlightcode(self): + idle_marker = "S.busy=false" + positions = [] + start = 0 + while True: + idx = SESSIONS_JS.find(idle_marker, start) + if idx < 0: + break + positions.append(idx) + start = idx + 1 + + found = False + for pos in positions: + block = SESSIONS_JS[pos : pos + 600] + has_highlight = "highlightCode()" in block + has_loaddir = "loadDir('.')" in block + if has_highlight and has_loaddir: + found = True + loaddir_idx = block.find("loadDir(") + highlight_idx = block.find("highlightCode()") + assert loaddir_idx < highlight_idx, ( + "In the idle path, loadDir() should be started before " + "highlightCode() so the network request is dispatched first." + ) + assert "await" in block and "_dirP" in block, ( + "loadDir() result should be stored and awaited after " + "highlightCode() completes." + ) + break + + assert found, ( + "Could not find the idle path in loadSession that calls both " + "loadDir and highlightCode." + ) + + +# ── 3. workspace.py: git_info_for_workspace is parallelized ──────────────── + + +class TestGitInfoParallel: + """git_info_for_workspace() must run git subprocess calls in parallel + to reduce wall-clock time.""" + + def test_uses_thread_pool(self): + source = pathlib.Path(__file__).parent.parent / "api" / "workspace.py" + src = source.read_text() + fn = src[src.find("def git_info_for_workspace") :] + fn = fn[: fn.find("\ndef ")] + + assert "concurrent.futures" in src, ( + "concurrent.futures should be imported at the module level." + ) + assert "ThreadPoolExecutor" in fn, ( + "git_info_for_workspace should use ThreadPoolExecutor " + "to run git commands in parallel." + ) + + def test_git_commands_run_concurrently(self, tmp_path): + """Proof that status/ahead/behind git commands execute in parallel, + not sequentially. Uses threading.Barrier to verify overlap.""" + from api.workspace import git_info_for_workspace + import api.workspace as ws_mod + + git_dir = tmp_path / ".git" + git_dir.mkdir() + + barrier = threading.Barrier(3, timeout=5) + call_count = {"n": 0} + started_times = [] + + def fake_git(args, cwd, timeout=3): + if args[0] == "rev-parse": + return "main" + call_count["n"] += 1 + started_times.append(time.monotonic()) + barrier.wait(timeout=2) + if args[0] == "status": + return "" + return "0" + + with patch.object(ws_mod, "_run_git", side_effect=fake_git): + result = git_info_for_workspace(tmp_path) + + assert result is not None + assert result["is_git"] is True + assert result["branch"] == "main" + assert call_count["n"] == 3, ( + f"Expected 3 parallel git calls, got {call_count['n']}" + ) + assert started_times[-1] - started_times[0] < 0.15, ( + f"Git commands started too far apart ({started_times[-1]-started_times[0]:.3f}s), " + f"suggesting serial execution." + ) + + def test_parallel_faster_than_serial(self, tmp_path): + """Wall-clock time for parallel execution should be ~1/3 of serial.""" + from api.workspace import git_info_for_workspace + import api.workspace as ws_mod + + git_dir = tmp_path / ".git" + git_dir.mkdir() + + def slow_git(args, cwd, timeout=3): + if args[0] == "rev-parse": + return "main" + time.sleep(0.1) + if args[0] == "status": + return "" + return "0" + + with patch.object(ws_mod, "_run_git", side_effect=slow_git): + t0 = time.monotonic() + result = git_info_for_workspace(tmp_path) + elapsed = time.monotonic() - t0 + + assert result is not None + assert result["is_git"] is True + assert elapsed < 0.25, ( + f"git_info_for_workspace took {elapsed:.3f}s — expected < 0.25s " + f"with parallel execution (serial baseline is ~0.3s)." + ) + + +# ── 4. Message pagination (msg_limit + msg_before) ───────────────────────── + + +class TestMessagePaginationBackend: + """Backend /api/session must support msg_limit and msg_before parameters + to return only the last N messages, reducing payload size for fast + session switching.""" + + def _make_session(self, n_msgs=100): + """Create a mock session with n_msgs messages.""" + session = MagicMock() + session.session_id = "test_session_123" + session.title = "Test Session" + session.workspace = "/tmp/test" + session.model = "test-model" + session.created_at = 1000000 + session.updated_at = 2000000 + session.pinned = False + session.archived = False + session.project_id = None + session.profile = None + session.input_tokens = 0 + session.output_tokens = 0 + session.estimated_cost = None + session.personality = None + session.active_stream_id = None + session.pending_user_message = None + session.pending_attachments = [] + session.pending_started_at = None + session.compression_anchor_visible_idx = None + session.compression_anchor_message_key = None + session._metadata_message_count = None + session.messages = [ + {"role": "user" if i % 3 == 0 else "assistant", "content": f"Message {i}"} + for i in range(n_msgs) + ] + session.tool_calls = [] + session.compact.return_value = { + "session_id": "test_session_123", + "title": "Test Session", + "workspace": "/tmp/test", + "model": "test-model", + "message_count": n_msgs, + "created_at": 1000000, + "updated_at": 2000000, + "last_message_at": 2000000, + "pinned": False, + "archived": False, + "project_id": None, + "profile": None, + "input_tokens": 0, + "output_tokens": 0, + "estimated_cost": None, + "personality": None, + "compression_anchor_visible_idx": None, + "compression_anchor_message_key": None, + "active_stream_id": None, + "is_streaming": False, + } + return session + + def test_msg_limit_returns_tail(self): + """msg_limit=10 should return the last 10 messages of a 100-msg session.""" + session = self._make_session(100) + all_msgs = session.messages + msg_limit = 10 + + truncated = all_msgs[-msg_limit:] + assert len(truncated) == 10 + assert truncated[0]["content"] == "Message 90" + assert truncated[-1]["content"] == "Message 99" + + def test_msg_limit_larger_than_total(self): + """msg_limit larger than total messages returns all messages.""" + session = self._make_session(50) + all_msgs = session.messages + msg_limit = 100 + + truncated = all_msgs[-msg_limit:] + assert len(truncated) == 50 + assert len(all_msgs) <= msg_limit + + def test_msg_before_index_based_slicing(self): + """msg_before=50 returns messages[:50] then tail window.""" + session = self._make_session(100) + all_msgs = session.messages + msg_before = 50 + msg_limit = 30 + + _slice = all_msgs[:msg_before] + truncated = _slice[-msg_limit:] + assert len(truncated) == 30 + assert truncated[0]["content"] == "Message 20" + assert truncated[-1]["content"] == "Message 49" + + def test_msg_before_zero_returns_empty(self): + """msg_before=0 means no older messages exist — returns empty.""" + session = self._make_session(100) + all_msgs = session.messages + msg_before = 0 + + _slice = all_msgs[:msg_before] + assert len(_slice) == 0 + + def test_msg_before_equal_total(self): + """msg_before=100 returns all 100, tail-30 gives messages 70-99.""" + session = self._make_session(100) + all_msgs = session.messages + msg_before = 100 + msg_limit = 30 + + _slice = all_msgs[:msg_before] + truncated = _slice[-msg_limit:] + assert len(truncated) == 30 + assert truncated[0]["content"] == "Message 70" + + def test_truncation_flag(self): + """_messages_truncated must be True when messages were omitted.""" + session = self._make_session(100) + msg_limit = 30 + is_truncated = len(session.messages) > msg_limit + assert is_truncated is True + + small = self._make_session(10) + is_truncated_small = len(small.messages) > msg_limit + assert is_truncated_small is False + + def test_truncation_flag_with_msg_before(self): + """When msg_before filters to fewer than msg_limit, truncation is False.""" + session = self._make_session(100) + msg_before = 10 + msg_limit = 30 + + _slice = session.messages[:msg_before] + _truncated = len(_slice) > msg_limit + assert _truncated is False # 10 < 30, no truncation + + def test_messages_offset_initial_load(self): + """_messages_offset = index of first returned message in full array.""" + session = self._make_session(100) + msg_limit = 30 + all_msgs = session.messages + + truncated = all_msgs[-msg_limit:] + offset = len(all_msgs) - len(truncated) + assert offset == 70 + assert truncated[0]["content"] == "Message 70" + + def test_messages_offset_with_msg_before(self): + """_messages_offset for msg_before=50, msg_limit=30.""" + session = self._make_session(100) + msg_before = 50 + msg_limit = 30 + + _slice = session.messages[:msg_before] + truncated = _slice[-msg_limit:] + offset = msg_before - len(truncated) + assert offset == 20 + assert truncated[0]["content"] == "Message 20" + + def test_payload_size_reduction(self): + """Quantify the payload reduction: 100 msgs → 30 msgs = ~70% smaller.""" + import json + + session = self._make_session(100) + all_json = json.dumps(session.messages) + tail_json = json.dumps(session.messages[-30:]) + + reduction = 1 - len(tail_json) / len(all_json) + assert reduction > 0.6, ( + f"Expected >60% payload reduction, got {reduction*100:.0f}%." + ) + + def test_msg_before_bounds_clamping(self): + """msg_before beyond array length should be clamped.""" + session = self._make_session(100) + all_msgs = session.messages + + # msg_before = 999 → clamped to 100 + _before_idx = max(0, min(999, len(all_msgs))) + assert _before_idx == 100 + + # msg_before = -5 → clamped to 0 + _before_idx = max(0, min(-5, len(all_msgs))) + assert _before_idx == 0 + + +class TestMessagePaginationFrontend: + """Frontend sessions.js must use msg_limit for initial load and expose + _loadOlderMessages for scroll-to-top lazy loading.""" + + def test_ensure_messages_uses_msg_limit(self): + """_ensureMessagesLoaded must send msg_limit parameter.""" + fn_start = SESSIONS_JS.find("async function _ensureMessagesLoaded") + fn_end = SESSIONS_JS.find("\n}", fn_start) + 2 + fn_body = SESSIONS_JS[fn_start:fn_end] + + assert "msg_limit=" in fn_body, ( + "_ensureMessagesLoaded should include msg_limit parameter in the API call" + ) + assert "_INITIAL_MSG_LIMIT" in fn_body, ( + "_ensureMessagesLoaded should use _INITIAL_MSG_LIMIT constant" + ) + + def test_truncation_tracking(self): + """_messagesTruncated must be set from the server response.""" + assert "_messagesTruncated" in SESSIONS_JS + assert "_messages_truncated" in SESSIONS_JS + + def test_oldest_idx_tracking(self): + """_oldestIdx must be tracked for index-based cursor paging.""" + assert "_oldestIdx" in SESSIONS_JS, ( + "sessions.js must track _oldestIdx for index-based cursor paging" + ) + assert "_messages_offset" in SESSIONS_JS, ( + "sessions.js must read _messages_offset from server response" + ) + + def test_load_older_messages_function_exists(self): + """_loadOlderMessages must be defined for scroll-to-top loading.""" + assert "async function _loadOlderMessages" in SESSIONS_JS + + def test_load_older_uses_index_cursor(self): + """_loadOlderMessages must pass msg_before as integer index, not timestamp.""" + fn_start = SESSIONS_JS.find("async function _loadOlderMessages") + fn_end = SESSIONS_JS.find("\n}", fn_start) + 2 + fn_body = SESSIONS_JS[fn_start:fn_end] + + assert "msg_before=${_oldestIdx}" in fn_body, ( + "_loadOlderMessages should use _oldestIdx (integer) as msg_before cursor" + ) + + def test_ensure_all_messages_function_exists(self): + """_ensureAllMessagesLoaded must exist for operations needing full history.""" + assert "async function _ensureAllMessagesLoaded" in SESSIONS_JS + + def test_scroll_to_top_triggers_loading(self): + """Scroll event handler must trigger _loadOlderMessages near top.""" + UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") + + assert "el.scrollTop<80" in UI_JS + assert "_loadOlderMessages" in UI_JS + + def test_load_older_indicator_in_render(self): + """renderMessages must show a 'load older' indicator when truncated.""" + UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") + + assert "loadOlderIndicator" in UI_JS + + def test_oldest_idx_reset_on_session_switch(self): + """_oldestIdx must be reset to 0 on session switch.""" + # Find the loadSession reset block + idx = SESSIONS_JS.find("_messagesTruncated = false;\n _oldestIdx = 0;") + assert idx >= 0, ( + "_oldestIdx must be reset to 0 alongside _messagesTruncated on session switch" + ) + + +# ── 5. Session-switch cancellation safety ─────────────────────────────────── + + +class TestSessionSwitchCancellation: + """When the user switches sessions while _loadOlderMessages is in-flight, + the stale response must NOT land on the new session's message array. + + Guarards in place: + - _loadOlderMessages captures `sid` at entry, checks _loadingSessionId + after await (line ~373) + - loadSession resets _loadingOlder, _messagesTruncated, _oldestIdx + on session switch (line ~120-122) + """ + + def test_load_older_checks_loading_session_id(self): + """_loadOlderMessages must check _loadingSessionId after await.""" + fn_start = SESSIONS_JS.find("async function _loadOlderMessages") + fn_end = SESSIONS_JS.find("\n}", fn_start) + 2 + fn_body = SESSIONS_JS[fn_start:fn_end] + + assert "_loadingSessionId" in fn_body, ( + "_loadOlderMessages must check _loadingSessionId after the API " + "call returns to detect session-switch race conditions." + ) + # The guard should be: if _loadingSessionId !== null && _loadingSessionId !== sid + assert "_loadingSessionId !== null" in fn_body or "_loadingSessionId!==null" in fn_body, ( + "_loadOlderMessages should bail out if a new session load started " + "while the older-messages request was in flight." + ) + + def test_loading_older_reset_on_session_switch(self): + """loadSession must reset _loadingOlder when switching sessions.""" + # Find the reset block in loadSession + marker = "_messagesTruncated = false;\n _oldestIdx = 0;\n _loadingOlder = false;" + idx = SESSIONS_JS.find(marker) + assert idx >= 0, ( + "loadSession must reset _loadingOlder=false on session switch " + "to prevent a stale _loadOlderMessages lock from blocking the " + "new session's scroll-to-top loading." + ) + + def test_stale_cannot_mutate_messages(self): + """Verify the guard prevents S.messages mutation. + + The guard `if (_loadingSessionId !== null && _loadingSessionId !== sid) return` + runs BEFORE `S.messages = [...olderMsgs, ...S.messages]`. + If the session changed, we return early — no mutation. + """ + fn_start = SESSIONS_JS.find("async function _loadOlderMessages") + fn_end = SESSIONS_JS.find("\n}", fn_start) + 2 + fn_body = SESSIONS_JS[fn_start:fn_end] + + # Guard must appear before S.messages mutation + guard_idx = fn_body.find("_loadingSessionId") + mutation_idx = fn_body.find("S.messages = [...olderMsgs") + assert guard_idx >= 0 and mutation_idx >= 0 and guard_idx < mutation_idx, ( + "The _loadingSessionId guard must appear BEFORE the S.messages " + "mutation to prevent stale data from landing on the wrong session." + ) + + def test_messages_truncated_reset_on_switch(self): + """loadSession must reset _messagesTruncated on session switch.""" + marker = "_messagesTruncated = false;\n _oldestIdx = 0;\n _loadingOlder = false;" + idx = SESSIONS_JS.find(marker) + assert idx >= 0, ( + "_messagesTruncated must be reset to false on session switch " + "to prevent the scroll-to-top handler from trying to load " + "older messages from the previous session." + ) + + def test_oldest_idx_reset_prevents_wrong_cursor(self): + """_oldestIdx=0 after switch prevents passing stale cursor to API.""" + # If _oldestIdx carried over from session A (e.g. _oldestIdx=70), + # and session B only has 10 messages, msg_before=70 would return empty. + # Resetting to 0 ensures session B starts fresh. + fn_start = SESSIONS_JS.find("async function _loadOlderMessages") + fn_end = SESSIONS_JS.find("\n}", fn_start) + 2 + fn_body = SESSIONS_JS[fn_start:fn_end] + + # _loadOlderMessages checks _oldestIdx <= 0 early and exits + assert "_oldestIdx <= 0" in fn_body, ( + "_loadOlderMessages should bail out if _oldestIdx <= 0, " + "which is the reset value after session switch." + ) + + def test_load_older_compares_against_active_session_id(self): + """_loadOlderMessages must verify S.session.session_id === sid after await. + + _loadingSessionId alone is insufficient: it is null between session + loads, so a stale older-messages response that lands AFTER a + completed session switch would otherwise pass the guard and prepend + onto the new session's S.messages. The S.session.session_id check + closes that window. + """ + fn_start = SESSIONS_JS.find("async function _loadOlderMessages") + fn_end = SESSIONS_JS.find("\n}", fn_start) + 2 + fn_body = SESSIONS_JS[fn_start:fn_end] + + assert "S.session.session_id !== sid" in fn_body, ( + "_loadOlderMessages must compare S.session.session_id against " + "the captured sid after await — _loadingSessionId is null " + "between sessions and would let a stale response through." + ) + # The S.session check must appear BEFORE the S.messages mutation. + active_check_idx = fn_body.find("S.session.session_id !== sid") + mutation_idx = fn_body.find("S.messages = [...olderMsgs") + assert active_check_idx >= 0 and mutation_idx >= 0 and active_check_idx < mutation_idx, ( + "Active-session guard must run before S.messages mutation." + ) diff --git a/tests/test_sprint37.py b/tests/test_sprint37.py index 173b2a5e..cf8c3de8 100644 --- a/tests/test_sprint37.py +++ b/tests/test_sprint37.py @@ -69,13 +69,22 @@ def test_workspace_panel_restore_sets_browse_mode(): def test_workspace_panel_restore_before_sync(): - """Restore must happen before syncWorkspacePanelState() so the state drives the initial render.""" + """Restore must happen before syncWorkspacePanelState() so the state drives the initial render. + + The boot IIFE has two paths: one for sessions with messages (restores panel pref before sync) + and one for empty/missing sessions (no panel to restore; syncs immediately). The test verifies + that in the normal session-restore path, the panel pref read precedes syncWorkspacePanelState(). + We find the panelPref read (localStorage...workspace-panel-pref) and the LAST + syncWorkspacePanelState() call — the final one is always in the normal restore path. + """ iife_idx = BOOT_JS.rfind("(async function") if iife_idx < 0: iife_idx = BOOT_JS.rfind("(async()=>{") iife_body = BOOT_JS[iife_idx:] - restore_pos = iife_body.find("hermes-webui-workspace-panel") - sync_pos = iife_body.find("syncWorkspacePanelState()") + # workspace-panel-pref is only read in the normal (has-messages) restore path + restore_pos = iife_body.find("hermes-webui-workspace-panel-pref") + # Use the LAST syncWorkspacePanelState() — this is the one in the normal restore path + sync_pos = iife_body.rfind("syncWorkspacePanelState()") assert restore_pos >= 0, "restore read must be present in boot IIFE" assert sync_pos >= 0, "syncWorkspacePanelState call must be present in boot IIFE" assert restore_pos < sync_pos, \