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, \