fix: batch v0.50.229 — session perf, ephemeral sessions, iOS zoom (#1183)
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)!
This commit is contained in:
parent
ef26d19549
commit
a091be6a8e
16 changed files with 903 additions and 61 deletions
39
CHANGELOG.md
39
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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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&&
|
||||
|
|
|
|||
|
|
@ -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 = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Loading conversation...</div>';
|
||||
}
|
||||
|
|
@ -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;';
|
||||
|
|
|
|||
|
|
@ -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;}
|
||||
}
|
||||
|
|
|
|||
13
static/ui.js
13
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||''):'';
|
||||
|
|
|
|||
|
|
@ -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){
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
559
tests/test_parallel_session_switch.py
Normal file
559
tests/test_parallel_session_switch.py
Normal file
|
|
@ -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."
|
||||
)
|
||||
|
|
@ -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, \
|
||||
|
|
|
|||
Loading…
Reference in a new issue