Merge remote-tracking branch 'origin/master' into peek-4741

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
nesquena-hermes committed 2026-06-23 17:06:17 +00:00
commit 235bfffaf0
36 files changed
+2495 -121

No files matched your search

+38
View File
@@ -7,6 +7,44 @@
- **Faster profile switching: the backend stops re-reading config and settings on the hot path.** Two behavior-preserving caching wins on the `/api/sessions` path that a profile switch hits cold (#4662, phase 2+3). (1) `reload_config()` no longer re-parses `config.yaml` from disk when the file is unchanged — it now routes through the same mtime-keyed parse cache used elsewhere, removing a ~hundreds-of-ms YAML reparse from each switch while preserving the exact process-env-expansion semantics that keep per-client profile isolation correct. (2) The sidebar session-list response now reads the redaction setting once per response instead of once per conversation row, eliminating a per-row `settings.json` read that grew into a multi-second serialization stage on large session lists. No user-visible behavior change — redaction still applies exactly as before; switching is just quicker. Part of the phased profile-switch performance work. (#4662)
## [v0.51.605] — 2026-06-23 — Release VL (trim hidden/empty rows from the sidebar payload)
### Changed
- **The `/api/sessions` sidebar payload no longer ships rows the browser drops locally.** Building on the source-tab pushdown (#4769), the default sidebar path was still serializing rescued `default_hidden` rows and plain 0-message rows that carry no visible-sidebar signal, only for the browser to discard them. These are now trimmed server-side before serialization, while messageful rows, rows with attention/unread/running state, active-stream rows, and the named-project-chip rescue (#3019) are all preserved request-shaped. Reduces payload size and serialization cost on the profile-switch hot path. Thanks @rodboev. (#4775)
## [v0.51.604] — 2026-06-23 — Release VK (live-stream worklog scroll + dedup + mobile session-switch scroll)
### Fixed
- **Compact Worklog no longer snaps back to the user prompt during live-stream rebuilds.** Follow-up to the mid-stream scroll restore: the live Anchor scene now holds the transcript's scroll height stable while it removes and rebuilds Worklog rows, so the browser cannot temporarily clamp an unpinned reader's `scrollTop` to the top before the replacement rows land. Pinned-at-bottom streaming still follows the latest output. Thanks @franksong2702. (#4778)
- **Compact Worklog no longer shows duplicate live process prose while a response is still streaming.** When the same progress sentence arrives through both interim assistant text and Anchor activity rows, the live scene now keeps one visible Process row, removes matching live reasoning echoes from the Worklog DOM, and hides legacy source assistant segments so they cannot appear as duplicate transcript prose. Thanks @franksong2702. (#4777)
- **Switching conversations on mobile now scrolls to the latest message instead of staying mid-transcript.** A stale manual-unpin flag left over from a stray touch event during session loading no longer blocks scroll-to-bottom; the scroll state is reset at the start of a genuine session switch. Thanks @jcarvine. (#4780)
## [v0.51.603] — 2026-06-23 — Release VJ (cache the app-shell template)
### Changed
- **The app shell (`/`, `/index.html`, `/session/<id>`) is now served from an in-process template cache instead of re-reading and re-rendering `static/index.html` (~190 KB) on every navigation.** These are the hottest routes; each request previously re-read the file from disk and re-applied two process-constant token substitutions (`__WEBUI_VERSION__`, `__MAX_UPLOAD_BYTES__`). The partially rendered template is now cached and invalidated on `(size, mtime_ns)` change — mirroring the existing static-asset cache — so a redeploy is still picked up without a restart. The per-session CSRF token and runtime extension tags are still applied per request, so output is byte-identical to before. Measured ~5× faster shell render in a local microbenchmark and removes per-request extension-manifest disk reads. Thanks @boriken72. (#4774)
## [v0.51.602] — 2026-06-23 — Release VI (sidebar fetches only the active source bucket)
### Changed
- **The sidebar now fetches only the active WebUI/CLI tab's sessions instead of pulling the full mixed list and discarding the inactive half in the browser.** The active tab is sent as a `sidebar_source` query param, the `/api/sessions` payload is filtered server-side before serialization (keyed distinctly per bucket in the list cache), and explicit per-bucket counts are returned so both tab labels stay accurate. On a tab switch the sidebar now repaints synchronously under the new bucket so stale cross-bucket rows can't linger. Thanks @rodboev. (#4766)
## [v0.51.601] — 2026-06-23 — Release VH (sidebar lineage rows keep running/unread state when continuation is hidden)
### Fixed
- Sidebar lineage rows now keep their running spinner, latest activity ordering, and unread/attention state when the active continuation is hidden as an archived lineage child. Pin/unpin actions also update the sidebar ordering immediately before the next server refresh. Thanks @santastabber. (#4451)
## [v0.51.600] — 2026-06-23 — Release VG (suppress footer jitter during virtual-scroll measurement)
### Fixed
- **The message footer (timestamp + usage stats + actions) no longer flickers/jitters during virtualized-list measurement re-renders.** While the virtual transcript re-measured row heights, the footer's fade transitions fired on each measurement pass, producing a visible jitter. Footer transitions are now suppressed for the duration of the measurement re-render only (a scoped `vscroll-measuring` class, removed in a `finally` so it can never leak), so the footer stays stable while scrolling a long conversation. Thanks @rodboev. (#4474)
## [v0.51.599] — 2026-06-23 — Release VF (dedupe live progress reasoning echoes)
### Fixed
+123 -9
View File
@@ -1597,7 +1597,10 @@ def _session_list_cache_key(
show_previous_messaging_sessions: bool,
show_cron_sessions: bool,
include_archived: bool = False,
exclude_hidden: bool = False,
visible_only: bool = False,
source_filter: str | None = None,
sidebar_source: str | None = None,
) -> tuple:
return (
_session_list_cache_profile_scope(active_profile),
@@ -1606,7 +1609,10 @@ def _session_list_cache_key(
bool(show_previous_messaging_sessions),
bool(show_cron_sessions),
bool(include_archived),
bool(exclude_hidden),
bool(visible_only),
source_filter,
sidebar_source,
)
@@ -1924,11 +1930,39 @@ def _build_session_list_cache_payload(
show_previous_messaging_sessions: bool,
show_cron_sessions: bool,
include_archived: bool = False,
exclude_hidden: bool = False,
visible_only: bool = False,
source_filter: str | None = None,
sidebar_source: str | None = None,
diag=None,
) -> dict:
diag_stage = diag.stage if diag is not None else lambda *_a, **_k: None
def _session_has_server_visible_messages(session: dict) -> bool:
"""Return True when a non-active sidebar row has a visibility signal.
Keep this mirror of the non-active server filter narrow and local to
route behavior so model-layer behavior remains unchanged.
"""
if not isinstance(session, dict):
return False
if _numeric_count(session.get("message_count")) > 0:
return True
attention = session.get("attention")
if not (isinstance(attention, dict) and attention.get("kind")):
attention = _session_attention_summary(str(session.get("session_id") or ""))
if isinstance(attention, dict) and attention.get("kind"):
if _numeric_count(attention.get("count")) > 0:
return True
return bool(
session.get("is_streaming")
or session.get("active_stream_id")
or session.get("pending_user_message")
or session.get("has_pending_user_message")
)
def _all_sessions_for_sidebar():
try:
return all_sessions(diag=diag, include_lineage_metadata=False)
@@ -2087,6 +2121,16 @@ def _build_session_list_cache_payload(
diag_stage("cli_cap")
archived_scoped = _cap_recent_cli_sessions(archived_scoped, cli_cap=CLI_VISIBLE_SESSION_CAP)
visible_scoped = _cap_recent_cli_sessions(visible_scoped, cli_cap=CLI_VISIBLE_SESSION_CAP)
if visible_only:
archived_scoped = [
s for s in archived_scoped if _session_has_server_visible_messages(s)
]
visible_scoped = [
s for s in visible_scoped if _session_has_server_visible_messages(s)
]
if exclude_hidden:
archived_scoped = [s for s in archived_scoped if not s.get("default_hidden")]
visible_scoped = [s for s in visible_scoped if not s.get("default_hidden")]
archived_webui_count = sum(
1 for s in archived_scoped
if s.get("archived") and not _is_cli_session_for_settings(s)
@@ -2097,6 +2141,18 @@ def _build_session_list_cache_payload(
)
archived_count = archived_webui_count + archived_cli_count
scoped = archived_scoped if include_archived else visible_scoped
webui_session_count = sum(
1 for s in scoped
if not _is_cli_session_for_settings(s)
)
cli_session_count = sum(
1 for s in scoped
if _is_cli_session_for_settings(s)
)
if sidebar_source == "webui":
scoped = [s for s in scoped if not _is_cli_session_for_settings(s)]
elif sidebar_source == "cli":
scoped = [s for s in scoped if _is_cli_session_for_settings(s)]
if not include_archived:
diag_stage("filter_archived_sessions")
diag_stage("visible_lineage_metadata")
@@ -2110,6 +2166,8 @@ def _build_session_list_cache_payload(
"archived_count": archived_count,
"archived_webui_count": archived_webui_count,
"archived_cli_count": archived_cli_count,
"webui_session_count": webui_session_count,
"cli_session_count": cli_session_count,
"include_archived": include_archived,
"all_profiles": all_profiles,
"active_profile": active_profile,
@@ -2140,7 +2198,7 @@ def _session_list_payload_to_response(payload: dict) -> dict:
for s in runtime_rows:
item = _sidebar_session_response_item(s, redact_enabled=_redact_enabled) if isinstance(s, dict) else {}
safe_merged.append(item)
return {
response = {
"sessions": safe_merged,
"cli_count": int(payload.get("cli_count", 0)),
"archived_count": int(payload.get("archived_count", 0)),
@@ -2153,6 +2211,11 @@ def _session_list_payload_to_response(payload: dict) -> dict:
"server_time": time.time(),
"server_tz": time.strftime("%z"),
}
if "webui_session_count" in payload:
response["webui_session_count"] = int(payload.get("webui_session_count", 0))
if "cli_session_count" in payload:
response["cli_session_count"] = int(payload.get("cli_session_count", 0))
return response
def _get_cached_session_list_payload(
@@ -8839,6 +8902,48 @@ def _save_saved_prompts(prompts: list) -> None:
p.write_text(json.dumps(prompts, ensure_ascii=False, indent=2), encoding="utf-8")
# In-process cache for the app-shell template. The `/`, `/index.html`, and
# `/session/<id>` routes are the hottest navigations and each re-read the
# ~190 KB static/index.html from disk and re-ran the two process-constant
# substitutions (__WEBUI_VERSION__, __MAX_UPLOAD_BYTES__) on every request.
# Those values are fixed for the process lifetime, so we cache the partially
# rendered template here, keyed by (size, nanosecond mtime) exactly like
# _STATIC_CACHE so a redeploy is picked up without a restart. The two values
# that genuinely vary per request — the per-session CSRF token and the runtime
# extension tags (inject_extension_tags) — are still applied on each request
# against the cached base, so caching changes no observable output.
_INDEX_SHELL_CACHE: dict = {}
_INDEX_SHELL_CACHE_LOCK = threading.Lock()
def _render_index_shell_base() -> str:
"""Return static/index.html with the process-constant tokens substituted.
Cached and invalidated on (size, mtime_ns) change. The CSRF token and
extension-tag injection are intentionally NOT applied here — they vary per
request and are applied by the caller against this base string.
"""
from api.updates import WEBUI_VERSION
st = _INDEX_HTML_PATH.stat()
sig = (st.st_size, st.st_mtime_ns)
with _INDEX_SHELL_CACHE_LOCK:
cached = _INDEX_SHELL_CACHE.get("base")
if cached and cached[0] == sig:
return cached[1]
from urllib.parse import quote
version_token = quote(WEBUI_VERSION, safe="")
base = (
_INDEX_HTML_PATH.read_text(encoding="utf-8")
.replace("__WEBUI_VERSION__", version_token)
.replace("__MAX_UPLOAD_BYTES__", str(MAX_UPLOAD_BYTES))
)
with _INDEX_SHELL_CACHE_LOCK:
_INDEX_SHELL_CACHE["base"] = (sig, base)
return base
def handle_get(handler, parsed) -> bool:
"""Handle all GET routes. Returns True if handled, False for 404."""
@@ -8860,9 +8965,6 @@ def handle_get(handler, parsed) -> bool:
if parsed.path in ("/", "/index.html") or parsed.path.startswith("/session/"):
try:
from urllib.parse import quote
from api.updates import WEBUI_VERSION
version_token = quote(WEBUI_VERSION, safe="")
from api.extensions import inject_extension_tags
csrf_token = ""
@@ -8876,11 +8978,11 @@ def handle_get(handler, parsed) -> bool:
except Exception:
csrf_token = ""
html = (
_INDEX_HTML_PATH.read_text(encoding="utf-8")
.replace("__WEBUI_VERSION__", version_token)
.replace("__MAX_UPLOAD_BYTES__", str(MAX_UPLOAD_BYTES))
.replace("__CSRF_TOKEN_JSON__", json.dumps(csrf_token))
# The disk read + process-constant token substitutions are cached;
# only the per-session CSRF token and per-request extension tags are
# applied here (see _render_index_shell_base).
html = _render_index_shell_base().replace(
"__CSRF_TOKEN_JSON__", json.dumps(csrf_token)
)
return t(
handler,
@@ -9745,6 +9847,12 @@ def handle_get(handler, parsed) -> bool:
active_profile = get_active_profile_name()
all_profiles = _all_profiles_enabled(parsed)
include_archived = _query_flag(parsed, "include_archived")
exclude_hidden = _query_flag(parsed, "exclude_hidden")
sidebar_source = parse_qs(parsed.query).get("sidebar_source", [""])[0].strip().lower() or None
if sidebar_source not in ("webui", "cli"):
sidebar_source = None
# /api/sessions is the default sidebar contract, so keep the route-owned
# visible-row filter in the shared cache builder for both cache hits and misses.
key = _session_list_cache_key(
active_profile=active_profile,
all_profiles=all_profiles,
@@ -9752,7 +9860,10 @@ def handle_get(handler, parsed) -> bool:
show_previous_messaging_sessions=show_previous_messaging_sessions,
show_cron_sessions=show_cron_sessions,
include_archived=include_archived,
exclude_hidden=exclude_hidden,
visible_only=True,
source_filter=agent_session_source_filter,
sidebar_source=sidebar_source,
)
# Keep the visible /api/sessions contract unchanged even though the
# heavy lifting now lives in the cache builder: profile scoping via
@@ -9767,7 +9878,10 @@ def handle_get(handler, parsed) -> bool:
show_previous_messaging_sessions=show_previous_messaging_sessions,
show_cron_sessions=show_cron_sessions,
include_archived=include_archived,
exclude_hidden=exclude_hidden,
visible_only=True,
source_filter=agent_session_source_filter,
sidebar_source=sidebar_source,
diag=diag,
),
diag=diag,
+34 -4
View File
@@ -2831,6 +2831,34 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
return false;
}
function _removeLiveReasoningEchoRows(visible){
const turn=$('liveAssistantTurn');
const blocks=turn&&typeof _assistantTurnBlocks==='function'?_assistantTurnBlocks(turn):null;
if(!blocks||!visible) return false;
let removed=false;
const selector=[
'.agent-activity-thinking[data-anchor-scene-row="1"]',
'.agent-activity-thinking[data-live-thinking="1"]',
'.wl-reason[data-worklog-anchor-reason="1"]',
'.wl-reason[data-worklog-reason-source="reasoning"]'
].join(',');
blocks.querySelectorAll(selector).forEach(row=>{
const textNode=row.querySelector&&(
row.querySelector('.thinking-card-body pre') ||
row.querySelector('.thinking-card-body')
);
const text=String((textNode&&textNode.textContent)||row.textContent||'');
if(!_stripCompactEchoSuffix(text, visible).removed) return;
row.remove();
removed=true;
});
if(removed&&typeof _syncToolCallGroupSummary==='function'){
blocks.querySelectorAll('.tool-worklog-group,.tool-call-group').forEach(group=>{
_syncToolCallGroupSummary(group);
});
}
return removed;
}
function _stripLiveReasoningEcho(visible){
let removed=false;
const durable=_stripCompactEchoSuffix(reasoningText, visible);
@@ -2844,11 +2872,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
removed=true;
}
const anchorRemoved=_stripAnchorReasoningEcho(visible);
const domRemoved=_removeLiveReasoningEchoRows(visible);
if(removed) syncInflightAssistantMessage();
if((removed||anchorRemoved)&&!String(liveReasoningText||'').trim()&&typeof removeThinking==='function'){
if((removed||anchorRemoved||domRemoved)&&!String(liveReasoningText||'').trim()&&typeof removeThinking==='function'){
removeThinking();
}
return removed||anchorRemoved;
return removed||anchorRemoved||domRemoved;
}
function _flushReasoningToAnchor(){
if(_anchorReasoningFlushed||!reasoningText) return;
@@ -3404,6 +3433,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
function _flushPendingSegmentRender(options={}){
const force=!!(options&&options.force);
const skipAnchorProcessProse=!!(options&&options.skipAnchorProcessProse);
if(!assistantBody||(!force&&!_renderPending)) return;
if(_renderPending) _cancelAnimationFramePendingStreamRender();
const displayText=segmentStart===0
@@ -3428,7 +3458,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
} else {
assistantBody.innerHTML=esc(displayText);
}
_upsertAnchorProcessProse(displayText,{sealed:force});
if(!skipAnchorProcessProse) _upsertAnchorProcessProse(displayText,{sealed:force});
if(typeof _syncLiveWorklogReasonsForAnchor==='function') _syncLiveWorklogReasonsForAnchor(assistantRow, displayText);
}
function _resetAssistantSegment(){
@@ -3871,7 +3901,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_completeAutomaticCompressionOnLiveProgress(activeSid);
ensureAssistantRow(true);
if(assistantRow) assistantRow.setAttribute('data-interim','1');
_flushPendingSegmentRender({force:true});
_flushPendingSegmentRender({force:true,skipAnchorProcessProse:true});
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
if(typeof closeCurrentLiveActivityGroup==='function') closeCurrentLiveActivityGroup();
_applyToAnchor('interim_assistant',d,e);
+194 -36
View File
@@ -175,6 +175,7 @@ let _sessionCompletionUnread = null;
let _sessionObservedStreaming = null;
const _sessionStreamingById = new Map();
const _sessionListSnapshotById = new Map();
const _sessionListSourceById = new Map();
let _sessionListPointerActive = false;
let _sessionListLastScrollAt = 0;
let _pendingSessionListPayload = null;
@@ -368,7 +369,11 @@ function _isSessionLocallyStreaming(s) {
}
function _isSessionEffectivelyStreaming(s) {
return Boolean(s && (s.is_streaming || _isSessionLocallyStreaming(s)));
return Boolean(s && (
s.is_streaming ||
_hasPendingUserMessageSignal(s) ||
_isSessionLocallyStreaming(s)
));
}
function _hasPendingUserMessageSignal(s) {
@@ -376,7 +381,7 @@ function _hasPendingUserMessageSignal(s) {
}
function _isServerIdleSessionRow(s) {
return Boolean(s && s.session_id && !s.is_streaming && !s.active_stream_id && !s.pending_user_message && !s.has_pending_user_message);
return Boolean(s && s.session_id && !s.is_streaming && !s.active_stream_id && !s.pending_user_message && !s.has_pending_user_message && !s.pending_started_at);
}
function _reconcileActiveSessionIdleStateFromList(serverRows) {
@@ -445,6 +450,16 @@ function _purgeStaleInflightEntries() {
if (s && s.session_id) sessionsById.set(s.session_id, s);
}
}
const sourceById = typeof _sessionListSourceById !== 'undefined'
&& _sessionListSourceById
&& typeof _sessionListSourceById.get === 'function'
? _sessionListSourceById
: null;
const currentSidebarSource = typeof _allSessionsScope !== 'undefined'
&& _allSessionsScope
&& typeof _allSessionsScope.sidebarSource === 'string'
? _allSessionsScope.sidebarSource
: null;
for (const sid of Object.keys(INFLIGHT)) {
// #4354: purge stale INFLIGHT even for a hung/idle session, BUT skip the one
// session actively mid-send (#2689 start-race) — during /api/chat/start the
@@ -454,6 +469,10 @@ function _purgeStaleInflightEntries() {
continue;
}
if (!sessionsById.has(sid)) {
const knownSource = sourceById ? sourceById.get(sid) : null;
if (currentSidebarSource && (!knownSource || knownSource !== currentSidebarSource)) {
continue;
}
// Session is absent from _allSessions — it was deleted / archived /
// filtered and can never stream again, so drop the entry.
delete INFLIGHT[sid];
@@ -470,8 +489,37 @@ function _purgeStaleInflightEntries() {
}
}
function _rememberSessionListSource(s, sid = null, allowScopeFallback = true) {
const resolvedSid = sid || (s && s.session_id);
if (!resolvedSid) return;
let source = null;
if (s && typeof _isCliSession === 'function') {
source = _isCliSession(s) ? 'cli' : 'webui';
}
if (!source && Array.isArray(_allSessions)) {
const cached = _allSessions.find(item => item && item.session_id === resolvedSid);
if (cached && typeof _isCliSession === 'function') {
source = _isCliSession(cached) ? 'cli' : 'webui';
}
}
if (!source
&& allowScopeFallback
&& typeof _allSessionsScope !== 'undefined'
&& _allSessionsScope
&& typeof _allSessionsScope.sidebarSource === 'string') {
source = _allSessionsScope.sidebarSource;
}
if (source
&& typeof _sessionListSourceById !== 'undefined'
&& _sessionListSourceById
&& typeof _sessionListSourceById.set === 'function') {
_sessionListSourceById.set(resolvedSid, source);
}
}
function _rememberRenderedStreamingState(s, isStreaming) {
if (!s || !s.session_id || !isStreaming) return;
if (typeof _rememberSessionListSource === 'function') _rememberSessionListSource(s);
_sessionStreamingById.set(s.session_id, true);
_rememberObservedStreamingSession(s);
}
@@ -576,6 +624,7 @@ function _renderRuntimeJournalAnchorActivityScene(activeStreamId, sid){
function _rememberRenderedSessionSnapshot(s) {
if (!s || !s.session_id) return;
if (typeof _rememberSessionListSource === 'function') _rememberSessionListSource(s);
const previous = _sessionListSnapshotById.get(s.session_id);
if (previous) return;
_sessionListSnapshotById.set(s.session_id, {
@@ -610,6 +659,7 @@ function _markSessionCompletedInList(session, previousSid = null) {
pending_started_at: null,
is_streaming: false,
};
if (typeof _rememberSessionListSource === 'function') _rememberSessionListSource(_allSessions[idx], finalSid);
_sessionStreamingById.set(finalSid, false);
_forgetObservedStreamingSession(finalSid);
if (previousSid && previousSid !== finalSid) {
@@ -621,6 +671,7 @@ function _markSessionCompletedInList(session, previousSid = null) {
_sessionStreamingById.delete(previousSid);
_forgetObservedStreamingSession(previousSid);
_sessionListSnapshotById.delete(previousSid);
_sessionListSourceById.delete(previousSid);
}
_sessionListSnapshotById.set(finalSid, {
message_count: messageCount,
@@ -632,17 +683,32 @@ function _markSessionCompletedInList(session, previousSid = null) {
function _markPollingCompletionUnreadTransitions(sessions) {
if (!Array.isArray(sessions)) return;
const seen = new Set();
const sourceById = typeof _sessionListSourceById !== 'undefined'
&& _sessionListSourceById
&& typeof _sessionListSourceById.get === 'function'
&& typeof _sessionListSourceById.keys === 'function'
&& typeof _sessionListSourceById.delete === 'function'
? _sessionListSourceById
: new Map();
const currentSidebarSource = typeof _allSessionsScope !== 'undefined'
&& _allSessionsScope
&& typeof _allSessionsScope.sidebarSource === 'string'
? _allSessionsScope.sidebarSource
: null;
for (const s of sessions) {
if (!s || !s.session_id) continue;
const sid = s.session_id;
seen.add(sid);
if (typeof _rememberSessionListSource === 'function') _rememberSessionListSource(s, sid);
const wasStreaming = _sessionStreamingById.get(sid);
const isStreaming = _isSessionEffectivelyStreaming(s);
const previousSnapshot = _sessionListSnapshotById.get(sid);
const observedStreaming = _getSessionObservedStreaming()[sid];
const messageCount = Number(s.message_count || 0);
const lastMessageAt = Number(s.last_message_at || 0);
const completedObservedStream = wasStreaming === true && !isStreaming;
const hasServerRunSignal=Boolean(s.is_streaming||_hasPendingUserMessageSignal(s));
const canMarkCompletedStream=Boolean(hasServerRunSignal||previousSnapshot||observedStreaming);
const completedObservedStream = canMarkCompletedStream&&wasStreaming === true && !isStreaming;
const completedWithNewMessages = Boolean(
(previousSnapshot || observedStreaming)
&& !isStreaming
@@ -671,11 +737,18 @@ function _markPollingCompletionUnreadTransitions(sessions) {
last_message_at: lastMessageAt,
});
}
for (const sid of Array.from(_sessionStreamingById.keys())) {
if (!seen.has(sid)) _sessionStreamingById.delete(sid);
}
for (const sid of Array.from(_sessionListSnapshotById.keys())) {
if (!seen.has(sid)) _sessionListSnapshotById.delete(sid);
const staleRuntimeStateSids = new Set([
...Array.from(_sessionStreamingById.keys()),
...Array.from(_sessionListSnapshotById.keys()),
...Array.from(sourceById.keys()),
]);
for (const sid of staleRuntimeStateSids) {
if (seen.has(sid)) continue;
const knownSource = sourceById.get(sid);
if (currentSidebarSource && (!knownSource || knownSource !== currentSidebarSource)) continue;
_sessionStreamingById.delete(sid);
_sessionListSnapshotById.delete(sid);
sourceById.delete(sid);
}
}
@@ -929,6 +1002,13 @@ async function loadSession(sid){
// Mark this session as the in-flight load. Subsequent loadSession() calls
// will overwrite this; stale awaits use the mismatch to bail out (#1060).
_loadingSessionId = sid;
// Reset scroll state for fresh session navigation — the reader expects to
// land at the bottom of the new transcript, not wherever a stale unpin flag
// from a prior session or a stray touch event during loading would place them.
if (currentSid !== sid && typeof _messageUserUnpinned !== 'undefined') {
_messageUserUnpinned = false;
_scrollPinned = true;
}
stopApprovalPolling();hideApprovalCard(forceReload);
if(typeof stopSessionStream==='function') stopSessionStream();
_yoloEnabled=false;_updateYoloPill();
@@ -1555,6 +1635,42 @@ function _sessionSourceLabel(filter, count) {
return filter === 'cli' ? `CLI sessions (${n})` : `WebUI sessions (${n})`;
}
function _clearSessionSourceTabCounts() {
_serverWebuiSessionCount = null;
_serverCliSessionCount = null;
}
function _requestedSessionSidebarSource() {
return window._showCliSessions ? _sessionSourceFilter : 'webui';
}
function _sessionListExcludeHiddenEnabled() {
return _activeProject===null || _activeProject===NO_PROJECT_FILTER;
}
function _sessionListQueryString() {
const qs = new URLSearchParams();
qs.set('sidebar_source', _requestedSessionSidebarSource());
if(_sessionListExcludeHiddenEnabled()) qs.set('exclude_hidden','1');
if(_showAllProfiles) qs.set('all_profiles','1');
if(_showArchived) qs.set('include_archived','1');
return `?${qs.toString()}`;
}
function _sessionSourceTabCount(filter, renderedWebuiSessionCount, renderedCliSessionCount) {
const serverCount = filter === 'cli' ? _serverCliSessionCount : _serverWebuiSessionCount;
if (Number.isFinite(serverCount)) return serverCount;
return filter === 'cli' ? renderedCliSessionCount : renderedWebuiSessionCount;
}
function _setActiveProjectFilter(projectId) {
const next = projectId === NO_PROJECT_FILTER ? NO_PROJECT_FILTER : (projectId || null);
if (_activeProject === next) return;
_activeProject = next;
renderSessionListFromCache();
void renderSessionList({deferWhileInteracting:false});
}
function _setSessionSourceFilter(filter) {
const next = filter === 'cli' ? 'cli' : 'webui';
if (_sessionSourceFilter === next) return;
@@ -1564,6 +1680,7 @@ function _setSessionSourceFilter(filter) {
_sessionSelectMode = false;
try { localStorage.setItem('hermes-session-source-filter', next); } catch (_e) {}
renderSessionListFromCache();
void renderSessionList({deferWhileInteracting:false});
}
function _restoreSessionSourceFilter() {
@@ -2667,6 +2784,8 @@ let _showAllProfiles = false; // false = filter to active profile only
let _otherProfileCount = 0; // count of sessions from other profiles (server-reported)
let _archivedWebuiCount = 0; // archived WebUI sessions not fetched until requested
let _archivedCliCount = 0; // archived non-WebUI sessions not fetched until requested
let _serverWebuiSessionCount = null; // explicit server count for WebUI sessions
let _serverCliSessionCount = null; // explicit server count for CLI sessions
let _sessionSourceFilter = 'webui'; // 'webui' keeps WebUI chats separate from read-only CLI sessions
_restoreSessionSourceFilter();
let _sessionActionMenu = null;
@@ -2974,7 +3093,7 @@ function _renderBatchActionBar(){
if(S.session&&ids.includes(S.session.session_id)){
S.session=null;S.messages=[];S.entries=[];localStorage.removeItem('hermes-webui-session');
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(null);
const remaining=await api('/api/sessions');
const remaining=await api('/api/sessions'+_sessionListQueryString());
if(remaining.sessions&&remaining.sessions.length){await loadSession(remaining.sessions[0].session_id);}
else{$('msgInner').innerHTML='';$('emptyState').style.display='';}
}
@@ -3345,8 +3464,11 @@ function _openSessionActionMenu(session, anchorEl){
try{
await api('/api/session/pin',{method:'POST',body:JSON.stringify({session_id:session.session_id,pinned:newPinned})});
session.pinned=newPinned;
const cached=(_allSessions||[]).find(s=>s&&s.session_id===session.session_id);
if(cached) cached.pinned=newPinned;
if(S.session&&S.session.session_id===session.session_id) S.session.pinned=newPinned;
renderSessionList();
renderSessionListFromCache();
void renderSessionList();
}catch(err){
showToast(t('session_pin_failed')+err.message);
await renderSessionList();
@@ -3616,6 +3738,7 @@ function _shouldKeepLocalOnlyOptimisticSessionRow(local){
function _dropStaleOptimisticSessionRow(sid){
if(!sid) return;
if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid, false);
if(INFLIGHT&&INFLIGHT[sid]){
delete INFLIGHT[sid];
if(typeof clearInflightState==='function') clearInflightState(sid);
@@ -3737,6 +3860,14 @@ function _applySessionListPayload(sessData, projData){
_otherProfileCount = sessData.other_profile_count || 0;
_archivedWebuiCount = Number(sessData.archived_webui_count ?? sessData.archived_count ?? 0);
_archivedCliCount = Number(sessData.archived_cli_count ?? 0);
_serverWebuiSessionCount = Object.prototype.hasOwnProperty.call(sessData, 'webui_session_count')
? Number(sessData.webui_session_count)
: null;
_serverCliSessionCount = Object.prototype.hasOwnProperty.call(sessData, 'cli_session_count')
? Number(sessData.cli_session_count)
: null;
if (!Number.isFinite(_serverWebuiSessionCount)) _serverWebuiSessionCount = null;
if (!Number.isFinite(_serverCliSessionCount)) _serverCliSessionCount = null;
// Capture server clock for clock-skew compensation (issue #1144).
// server_time is epoch seconds from the server's time.time().
// _serverTimeDelta = client - server, so (Date.now() - _serverTimeDelta)
@@ -3761,6 +3892,8 @@ function _applySessionListPayload(sessData, projData){
? sessData.active_profile
: (S.activeProfile || 'default'),
allProfiles: !!_showAllProfiles,
sidebarSource: _requestedSessionSidebarSource(),
excludeHidden: _sessionListExcludeHiddenEnabled(),
};
_syncSessionAttentionSoundState(_allSessions);
_pruneLineageReportCacheToVisibleSessions(_allSessions);
@@ -3768,7 +3901,7 @@ function _applySessionListPayload(sessData, projData){
_sessionListLoadError = null;
_sessionListHasLoadedOnce = true;
_markPollingCompletionUnreadTransitions(_allSessions);
const isStreaming = _allSessions.some(s => Boolean(s && s.is_streaming));
const isStreaming = _allSessions.some(s => _isSessionEffectivelyStreaming(s));
if (isStreaming) {
startStreamingPoll();
} else {
@@ -3817,10 +3950,7 @@ async function _runRenderSessionListRefresh(opts, _gen){
if(!deferWhileInteracting) _pendingSessionListPayload=null;
try{
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
const qs = new URLSearchParams();
if(_showAllProfiles) qs.set('all_profiles','1');
if(_showArchived) qs.set('include_archived','1');
const sessionListQS = qs.toString() ? `?${qs.toString()}` : '';
const sessionListQS = _sessionListQueryString();
const sessionRequestOpts={
timeoutToast:false,
timeoutMs:_sessionListHasLoadedOnce?30000:_SESSION_LIST_BOOT_TIMEOUT_MS,
@@ -3868,10 +3998,14 @@ async function _runRenderSessionListRefresh(opts, _gen){
const _curScope = {
profile: S.activeProfile || 'default',
allProfiles: !!_showAllProfiles,
sidebarSource: _requestedSessionSidebarSource(),
excludeHidden: _sessionListExcludeHiddenEnabled(),
};
const _scopeMatches = _allSessionsScope
&& _allSessionsScope.profile === _curScope.profile
&& _allSessionsScope.allProfiles === _curScope.allProfiles;
&& _allSessionsScope.allProfiles === _curScope.allProfiles
&& _allSessionsScope.sidebarSource === _curScope.sidebarSource
&& _allSessionsScope.excludeHidden === _curScope.excludeHidden;
// #4671: the /api/sessions fetch failed — clear the skeleton flag so this error
// render (matched cache, or empty rows for a mismatched scope) replaces the
// up-front profile-switch skeleton instead of stranding it.
@@ -3880,7 +4014,8 @@ async function _runRenderSessionListRefresh(opts, _gen){
renderSessionListFromCache();
} else {
_allSessions = [];
_allSessionsScope = null;
_allSessionsScope = _curScope;
_clearSessionSourceTabCounts();
renderSessionListFromCache();
}
}
@@ -4597,7 +4732,7 @@ function filterSessions(){
}
function _sessionTimestampMs(session) {
const raw = Number(session && (session.last_message_at || session.updated_at || session.created_at || 0));
const raw = Number(session && (session._sidebar_activity_at || session.last_message_at || session.updated_at || session.created_at || 0));
return Number.isFinite(raw) ? raw * 1000 : 0;
}
@@ -4991,6 +5126,8 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere
delete row._child_session_streaming;
delete row._child_session_has_unread;
delete row._child_session_attention;
delete row._child_session_latest_at;
delete row._sidebar_activity_at;
return row;
};
const rows=(collapsedRows||[])
@@ -5005,6 +5142,14 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere
const bubbleSidebarState=(parentRow, childRow)=>{
if(isChildStreaming(childRow)) parentRow._child_session_streaming=true;
if(childHasUnread(childRow)) parentRow._child_session_has_unread=true;
const childActivityRaw=childRow
? (childRow._sidebar_activity_at??childRow.last_message_at??childRow.updated_at??childRow.created_at??0)
: 0;
const childActivitySec=Number(childActivityRaw);
if(Number.isFinite(childActivitySec)&&childActivitySec>Number(parentRow._child_session_latest_at||0)){
parentRow._child_session_latest_at=childActivitySec;
parentRow._sidebar_activity_at=Math.max(Number(parentRow.last_message_at||parentRow.updated_at||parentRow.created_at||0), childActivitySec);
}
const childAttention=childRow&&childRow.attention&&typeof childRow.attention==='object'?childRow.attention:null;
if(!childAttention||!childAttention.kind||!Number.isFinite(Number(childAttention.count))||Number(childAttention.count)<=0) return;
const priorityFor=(kind)=>kind==='approval'?3:(kind==='clarify'?2:1);
@@ -5060,12 +5205,21 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere
return false;
};
const orphans=[];
const attachQueue=[...(rawSessions||[])].sort((a,b)=>attachDepthFor(a)-attachDepthFor(b));
const renderableChildIds=new Set((rawSessions||[]).map(s=>s&&s.session_id).filter(Boolean));
const attachQueueById=new Map();
for(const candidate of [...(rawSessions||[]),...(referenceSessions||[])]){
if(candidate&&candidate.session_id&&!attachQueueById.has(candidate.session_id)) attachQueueById.set(candidate.session_id,candidate);
}
const attachQueue=[...attachQueueById.values()].sort((a,b)=>attachDepthFor(a)-attachDepthFor(b));
for(const child of attachQueue){
const childRenderable=!!(child&&child.session_id&&renderableChildIds.has(child.session_id));
if(child&&child.session_id&&visibleBySid.has(child.session_id)) continue;
const isForkChild=_isForkWithResolvableParent(child, sessionIdsInList)&&!(child&&child.pinned);
if(!_isChildSession(child)&&!isForkChild) continue;
const childLineageKey=child&&(child._lineage_root_id||child.lineage_root_id||child.parent_session_id);
const isHiddenLineageReferenceChild=!!(child&&child.archived&&child.parent_session_id&&childLineageKey&&!child.pinned&&!childRenderable);
if(!_isChildSession(child)&&!isForkChild&&!isHiddenLineageReferenceChild) continue;
if(!isForkChild&&child._cross_surface_child_session){
orphans.push({...child,_orphan_child_session:true});
if(childRenderable) orphans.push({...child,_orphan_child_session:true});
continue;
}
const parentSid=child.parent_session_id;
@@ -5079,22 +5233,27 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere
if(!parentRow&&child._parent_lineage_root_id){
parentRow=visibleByLineageKey.get(child._parent_lineage_root_id)||null;
}
if(!parentRow){
parentRow=visibleByLineageKey.get(childLineageKey||parentSid)||null;
}
if(!parentRow&&hasHiddenArchivedAncestor(child)){
hiddenArchivedChildTree.add(child.session_id);
continue;
}
if(parentRow){
if(!Array.isArray(parentRow._child_sessions)) parentRow._child_sessions=[];
const childCopy={...child};
if(parentSegment){
childCopy._parent_segment_id=parentSegment.session_id;
childCopy._parent_segment_title=_sessionDisplayTitle(parentSegment)||child.parent_title||'Untitled';
}
parentRow._child_sessions.push(childCopy);
parentRow._child_session_count=parentRow._child_sessions.length;
if(childRenderable&&!isHiddenLineageReferenceChild){
if(!Array.isArray(parentRow._child_sessions)) parentRow._child_sessions=[];
parentRow._child_sessions.push(childCopy);
parentRow._child_session_count=parentRow._child_sessions.length;
}
bubbleSidebarState(parentRow, childCopy);
visibleBySegmentSid.set(childCopy.session_id,{row: parentRow, seg: childCopy});
} else {
} else if(childRenderable) {
orphans.push({...child,_orphan_child_session:true});
}
}
@@ -5240,6 +5399,7 @@ function _ensureActiveSessionRowPresent(rows, sourceRows){
function clearOptimisticSessionStreaming(sid){
sid=sid||(S.session&&S.session.session_id)||'';
if(!sid) return;
if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid, false);
if(S.session&&S.session.session_id===sid){
S.session.active_stream_id=null;
S.activeStreamId=null;
@@ -5517,12 +5677,10 @@ function renderSessionListFromCache(){
}=_partitionSidebarSessionRows(allMatched, activeSidForSidebar);
const referenceRaw=_sessionSourceFilter==='cli'?cliReferenceRaw:webuiReferenceRaw;
const sessions=_renderSidebarRowsFromRawSessions(sessionsRaw, referenceRaw);
const renderedWebuiSessionCount=_sessionSourceFilter==='webui'
? sessions.length
: _renderSidebarRowsFromRawSessions(webuiSessionsRaw, webuiReferenceRaw).length;
const renderedCliSessionCount=_sessionSourceFilter==='cli'
? sessions.length
: _renderSidebarRowsFromRawSessions(cliSessionsRaw, cliReferenceRaw).length;
const renderedWebuiSessionCount=_renderSidebarRowsFromRawSessions(webuiSessionsRaw, webuiReferenceRaw).length;
const renderedCliSessionCount=_renderSidebarRowsFromRawSessions(cliSessionsRaw, cliReferenceRaw).length;
const webuiSessionTabCount=_sessionSourceTabCount('webui', renderedWebuiSessionCount, renderedCliSessionCount);
const cliSessionTabCount=_sessionSourceTabCount('cli', renderedWebuiSessionCount, renderedCliSessionCount);
_syncSidebarExpansionForActiveSession(sessions, activeSidForSidebar);
const list=$('sessionList');
const animateRefresh=_sessionListRefreshAnimationPending;
@@ -5583,7 +5741,7 @@ function renderSessionListFromCache(){
const sourceTabs=document.createElement('div');
sourceTabs.className='session-source-tabs';
for(const filter of ['webui','cli']){
const count=filter==='cli'?renderedCliSessionCount:renderedWebuiSessionCount;
const count=filter==='cli'?cliSessionTabCount:webuiSessionTabCount;
const btn=document.createElement('button');
btn.type='button';
btn.className='session-source-tab'+(_sessionSourceFilter===filter?' active':'');
@@ -5604,7 +5762,7 @@ function renderSessionListFromCache(){
const allChip=document.createElement('span');
allChip.className='project-chip'+(!_activeProject?' active':'');
allChip.textContent='All';
allChip.onclick=()=>{_activeProject=null;renderSessionListFromCache();};
allChip.onclick=()=>{_setActiveProjectFilter(null);};
bar.appendChild(allChip);
// "Unassigned" chip — only when there are sessions with no project to
// filter to. Hidden in the common case where every session is already
@@ -5614,7 +5772,7 @@ function renderSessionListFromCache(){
noneChip.className='project-chip no-project'+(_activeProject===NO_PROJECT_FILTER?' active':'');
noneChip.textContent='Unassigned';
noneChip.title='Show conversations not yet assigned to a project';
noneChip.onclick=()=>{_activeProject=NO_PROJECT_FILTER;renderSessionListFromCache();};
noneChip.onclick=()=>{_setActiveProjectFilter(NO_PROJECT_FILTER);};
bar.appendChild(noneChip);
}
// Project chips
@@ -5633,7 +5791,7 @@ function renderSessionListFromCache(){
let _pClickTimer=null;
chip.onclick=(e)=>{
clearTimeout(_pClickTimer);
_pClickTimer=setTimeout(()=>{_pClickTimer=null;_activeProject=p.project_id;renderSessionListFromCache();},220);
_pClickTimer=setTimeout(()=>{_pClickTimer=null;_setActiveProjectFilter(p.project_id);},220);
};
chip.ondblclick=(e)=>{e.stopPropagation();clearTimeout(_pClickTimer);_pClickTimer=null;_startProjectRename(p,chip);};
chip.oncontextmenu=(e)=>{e.preventDefault();_showProjectContextMenu(e,p,chip);};
@@ -6963,7 +7121,7 @@ async function deleteSession(sid, beforeDelete=null){
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(null);
localStorage.removeItem('hermes-webui-session');
// load the most recent remaining session, or show blank if none left
const remaining=await api('/api/sessions');
const remaining=await api('/api/sessions'+_sessionListQueryString());
if(remaining.sessions&&remaining.sessions.length){
await loadSession(remaining.sessions[0].session_id);
}else{
+4
View File
@@ -2728,6 +2728,10 @@
/* ── Message action buttons (copy, edit, retry) ── */
.msg-actions{display:flex;align-items:center;gap:2px;opacity:0;transition:opacity .15s;margin-left:auto;}
.msg-row:hover .msg-actions{opacity:1;}
/* Keep !important so this beats equal-specificity .msg-foot-with-usage fades during measurement rerenders. */
.vscroll-measuring .msg-foot,
.vscroll-measuring .msg-actions,
.vscroll-measuring .msg-time{transition:none !important;}
.msg-action-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;padding:2px 5px;border-radius:5px;transition:color .12s,background .12s;line-height:1;}
.msg-action-btn:hover{color:var(--accent-text);background:var(--accent-bg);}
.selected-text-reply-btn{position:fixed;z-index:1200;display:inline-flex;align-items:center;gap:6px;padding:7px 11px;border:2px solid var(--accent);border-radius:999px;background:var(--bg);color:var(--text);box-shadow:0 8px 24px rgba(0,0,0,.26),0 0 0 1px var(--surface);font-size:12px;font-weight:700;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transform:translateY(4px);transition:opacity .12s ease,transform .12s ease,background .12s ease,border-color .12s ease;user-select:none;}
+76 -2
View File
@@ -871,7 +871,8 @@ function _compensateScrollForMeasurementDelta(renderFn){
if(!container) return renderFn();
const anchorBefore=_captureMessageViewportAnchor();
const scrollTopBefore=container.scrollTop;
renderFn();
container.classList.add('vscroll-measuring');
try{ renderFn(); }finally{ container.classList.remove('vscroll-measuring'); }
if(!anchorBefore) return;
if(scrollTopBefore<1){
const spacer=container.querySelector('[data-virtual-spacer="before"]');
@@ -9078,6 +9079,7 @@ function _syncWorklogReasonFromAnchor(group, anchor, displayTextOverride){
if(anchor){
anchor.classList.add('assistant-segment-worklog-source');
anchor.setAttribute('aria-hidden','true');
anchor.hidden=true;
}
}
function ensureLiveWorklogContainer(blocks, opts){
@@ -9141,6 +9143,7 @@ function _appendWorklogReason(list, anchor){
if(anchor){
anchor.classList.add('assistant-segment-worklog-source');
anchor.setAttribute('aria-hidden','true');
anchor.hidden=true;
}
return reason;
}
@@ -9262,8 +9265,11 @@ function _appendWorklogStep(group, anchor, cards, thinkingText, opts){
function _anchorSceneRowsForRendering(scene, opts){
const rows=Array.isArray(scene&&scene.activity_rows)?scene.activity_rows:[];
const settled=!!(opts&&opts.settled);
const live=!settled;
const out=[];
const byKey=new Map();
const liveProseTextKeys=new Map();
const proseTextKey=(value)=>String(value||'').replace(/\s+/g,' ').trim();
const keyFor=(row)=>{
if(!row) return '';
if(row.role==='tool') return `tool:${_anchorSceneToolRowLogicalKey(row)||row.row_id||row.event_id||row.local_id||out.length}`;
@@ -9285,8 +9291,23 @@ function _anchorSceneRowsForRendering(scene, opts){
const key=keyFor(row);
if(byKey.has(key)){
const index=byKey.get(key);
if(live&&row.role==='prose'){
const textKey=proseTextKey(text);
const duplicateIndex=textKey?liveProseTextKeys.get(textKey):undefined;
if(duplicateIndex!==undefined&&duplicateIndex!==index) continue;
const previousTextKey=proseTextKey(out[index]&&out[index].text);
if(previousTextKey&&previousTextKey!==textKey&&liveProseTextKeys.get(previousTextKey)===index){
liveProseTextKeys.delete(previousTextKey);
}
if(textKey) liveProseTextKeys.set(textKey,index);
}
out[index]=row.role==='tool'?_anchorSceneMergeToolRows(out[index],row):row;
}else{
if(live&&row.role==='prose'){
const textKey=proseTextKey(text);
if(textKey&&liveProseTextKeys.has(textKey)) continue;
if(textKey) liveProseTextKeys.set(textKey,out.length);
}
byKey.set(key,out.length);
out.push(row);
}
@@ -9570,6 +9591,44 @@ function _projectLiveAnchorActivitySceneForStream(streamId, mode){
return null;
}
}
function _prepareLiveAnchorScrollRebuildGuard(scrollSnapshot){
const messagesEl=$('messages');
if(!messagesEl||!scrollSnapshot) return {readerAwayFromBottom:false,release:null};
const beforeBottomDistance=Math.max(0,messagesEl.scrollHeight-messagesEl.scrollTop-messagesEl.clientHeight);
// Only treat the reader as away if they were ALREADY in a non-follow state.
// A pinned follower can transiently have bottomDistance>250 mid-render (the
// assistant body grows before the anchor scene re-renders), so keying on a
// raw scrollTop>0 here would mis-classify a pinned reader as unpinned and kill
// auto-follow. Require an explicit unpin/non-pinned signal instead.
const readerAwayFromBottom=beforeBottomDistance>250&&(_messageUserUnpinned||_scrollPinned===false);
if(!readerAwayFromBottom) return {readerAwayFromBottom:false,release:null};
scrollSnapshot.pinned=false;
scrollSnapshot.userUnpinned=true;
scrollSnapshot.bottom=beforeBottomDistance;
_messageUserUnpinned=true;
_scrollPinned=false;
_nearBottomCount=0;
const msgInner=$('msgInner');
if(!msgInner||!msgInner.style) return {readerAwayFromBottom:true,release:null};
const guardPreviousKey='liveAnchorScrollGuardPreviousMinHeight';
let previousMinHeight=msgInner.style.minHeight||'';
if(msgInner.dataset&&Object.prototype.hasOwnProperty.call(msgInner.dataset,guardPreviousKey)){
previousMinHeight=msgInner.dataset[guardPreviousKey]||'';
}else if(msgInner.dataset){
msgInner.dataset[guardPreviousKey]=previousMinHeight;
}
const guardHeight=Math.max(messagesEl.scrollHeight,Number(scrollSnapshot.scrollHeight)||0);
if(guardHeight>0) msgInner.style.minHeight=`${guardHeight}px`;
return {
readerAwayFromBottom:true,
release:()=>{
msgInner.style.minHeight=previousMinHeight;
if(msgInner.dataset&&msgInner.dataset[guardPreviousKey]===previousMinHeight){
delete msgInner.dataset[guardPreviousKey];
}
},
};
}
function renderLiveAnchorActivityScene(streamId, scene, opts){
opts=opts||{};
if(typeof isCompactWorklogMode==='function'&&!isCompactWorklogMode()) return false;
@@ -9594,11 +9653,13 @@ function renderLiveAnchorActivityScene(streamId, scene, opts){
? _captureWorklogDetailDisclosureState(blocks)
: null;
const scrollSnapshot=_captureMessageScrollSnapshot();
const scrollRebuildGuard=_prepareLiveAnchorScrollRebuildGuard(scrollSnapshot);
blocks.querySelectorAll('[data-anchor-scene-owner="1"],[data-anchor-scene-row="1"]').forEach(el=>el.remove());
blocks.querySelectorAll('.live-worklog[data-live-worklog-shell="1"],.tool-worklog-group[data-live-tool-call-group="1"],.tool-call-group[data-live-tool-call-group="1"],.tool-card-row[data-live-tid]:not(.transparent-event-row),.agent-activity-thinking[data-live-thinking="1"],.interim-collapse-toggle').forEach(el=>el.remove());
blocks.querySelectorAll('[data-live-assistant="1"]').forEach(el=>{
el.classList.add('assistant-segment-worklog-source');
el.setAttribute('aria-hidden','true');
el.hidden=true;
});
const group=_anchorSceneWorklogGroup(blocks,{
live:true,
@@ -9618,7 +9679,16 @@ function renderLiveAnchorActivityScene(streamId, scene, opts){
_dedupeLiveProcessedWorklogAnchors(turn);
if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd();
_restoreMessageScrollSnapshotSameFrame(scrollSnapshot);
if(typeof scrollIfPinned==='function') scrollIfPinned();
if(scrollRebuildGuard&&scrollRebuildGuard.release){
requestAnimationFrame(()=>{
scrollRebuildGuard.release();
// Only re-restore the unpinned snapshot if the reader is STILL unpinned at
// rAF time. If they re-pinned between guard-engage and this frame, the
// stale re-restore would yank them back off the bottom (Opus gate finding).
if(_messageUserUnpinned) _restoreMessageScrollSnapshotSameFrame(scrollSnapshot);
});
}
if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned();
return true;
}
function _renderLiveAnchorActivitySceneForStream(streamId, sessionId, opts){
@@ -9663,6 +9733,7 @@ function _renderSettledAnchorSceneTransparentForMessage(message, segment, rawIdx
if(Number.isFinite(idx)&&idx<rawIdx){
node.classList.add('assistant-segment-worklog-source');
node.setAttribute('aria-hidden','true');
node.hidden=true;
}
});
let wrote=false;
@@ -9695,6 +9766,7 @@ function _renderSettledAnchorSceneForMessage(message, segment, rawIdx){
if(Number.isFinite(idx)&&idx<rawIdx){
node.classList.add('assistant-segment-worklog-source');
node.setAttribute('aria-hidden','true');
node.hidden=true;
}
});
blocks.querySelectorAll('.tool-worklog-group:not([data-anchor-scene-owner="1"]),.tool-call-group:not([data-anchor-scene-owner="1"]),.agent-activity-thinking:not([data-anchor-scene-row="1"]),.wl-reason').forEach(el=>el.remove());
@@ -11410,6 +11482,7 @@ function renderMessages(options){
if(messageBelongsInWorklog){
seg.classList.add('assistant-segment-worklog-source');
seg.setAttribute('aria-hidden','true');
seg.hidden=true;
}
if(m._live){
currentAssistantTurn.id='liveAssistantTurn';
@@ -12147,6 +12220,7 @@ function renderMessages(options){
if(!(seg.textContent||'').trim()) continue;
seg.classList.remove('assistant-segment-worklog-source');
seg.removeAttribute('aria-hidden');
seg.hidden=false;
}
}
}
+8 -18
View File
@@ -1,16 +1,5 @@
"""Regression tests for #4167 review items — sidebar payload allowlist must
preserve read-only + gateway-routing fields, and the failed-refresh path must
not re-render a prior profile's sessions after a profile switch.
"""Regression tests for #4167 sidebar payload and scope guards."""
Item 1/2: _SIDEBAR_SESSION_RESPONSE_FIELDS must include read_only / is_read_only
(so the sidebar suppresses rename / action-menu / swipe on read-only sessions)
and gateway_routing (so the model label renders). Dropping these silently
regressed both surfaces.
Item 3: renderSessionList()'s catch path must scope-tag the cache and clear it
when the requested profile scope differs, instead of unconditionally
re-rendering the cached (prior-profile) rows.
"""
import re
import sys
from pathlib import Path
@@ -35,7 +24,6 @@ def test_sidebar_response_item_preserves_read_only_flag():
fn = getattr(routes, "_sidebar_session_response_item", None)
if fn is None:
# Helper name differs across versions — fall back to allowlist assertion.
assert "read_only" in routes._SIDEBAR_SESSION_RESPONSE_FIELDS
return
row = fn({
@@ -51,12 +39,14 @@ def test_sidebar_response_item_preserves_read_only_flag():
def test_failed_refresh_clears_cache_on_profile_scope_change():
"""The catch path in renderSessionList must clear stale rows when the
requested scope differs from the cached scope (no cross-profile leak)."""
"""The catch path must reject cached rows from a mismatched sidebar scope."""
src = (_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
# The cache must be scope-tagged.
assert "_allSessionsScope" in src, "cache is not scope-tagged"
# The catch path must compare current scope to the cached scope and clear
# _allSessions when they differ rather than always rendering from cache.
assert re.search(r"_scopeMatches", src), "catch path does not gate fallback on scope match"
assert re.search(r"_allSessions\s*=\s*\[\]", src), "catch path does not clear stale rows on scope mismatch"
assert "excludeHidden: _sessionListExcludeHiddenEnabled()" in src, (
"scope tagging must include the hidden-filter query mode"
)
assert "_allSessionsScope.excludeHidden === _curScope.excludeHidden" in src, (
"catch path must reject cached rows fetched under the wrong hidden-filter mode"
)
+23
View File
@@ -2330,6 +2330,29 @@ def test_importing_older_gateway_session_preserves_original_timestamps_and_order
{'session_id': newer_webui_sid, 'title': 'Newer WebUI Session'},
)
assert rename_status == 200, rename
from api.models import Session
from tests.conftest import TEST_WORKSPACE
newer_webui_session = Session(
session_id=newer_webui_sid,
title='Newer WebUI Session',
workspace=str(TEST_WORKSPACE),
model='openai/gpt-5',
created_at=newer_webui['session']['created_at'],
updated_at=time.time(),
profile='default',
messages=[{
'role': 'user',
'content': 'newer visible row',
'timestamp': time.time(),
}],
tool_calls=[],
)
newer_webui_session.save(touch_updated_at=False)
rename_refresh, rename_refresh_status = post(
'/api/session/rename',
{'session_id': newer_webui_sid, 'title': 'Newer WebUI Session'},
)
assert rename_refresh_status == 200, rename_refresh
_insert_gateway_session(
conn,
+87
View File
@@ -0,0 +1,87 @@
"""Regression tests for the app-shell template cache (_render_index_shell_base).
The `/`, `/index.html`, and `/session/<id>` routes are the hottest navigations.
Each previously re-read the ~190 KB static/index.html from disk and re-ran the
two process-constant token substitutions (__WEBUI_VERSION__,
__MAX_UPLOAD_BYTES__) on every request. _render_index_shell_base() caches the
partially rendered template, keyed by (size, mtime_ns) like _STATIC_CACHE, while
the per-session CSRF token and per-request extension tags stay per-request.
These tests guard the load-bearing invariant: caching must change no observable
output, only avoid redundant work.
"""
from __future__ import annotations
import json
import os
import time
from urllib.parse import quote
import api.routes as routes
from api.updates import WEBUI_VERSION
def _old_inline_render(csrf_token: str) -> str:
"""Reproduce the pre-cache inline render exactly, for equivalence checks."""
return (
routes._INDEX_HTML_PATH.read_text(encoding="utf-8")
.replace("__WEBUI_VERSION__", quote(WEBUI_VERSION, safe=""))
.replace("__MAX_UPLOAD_BYTES__", str(routes.MAX_UPLOAD_BYTES))
.replace("__CSRF_TOKEN_JSON__", json.dumps(csrf_token))
)
def test_base_substitutes_process_constants_but_not_csrf():
base = routes._render_index_shell_base()
assert "__WEBUI_VERSION__" not in base
assert "__MAX_UPLOAD_BYTES__" not in base
# CSRF must remain a placeholder — it varies per request and is applied by
# the caller, not baked into the shared cache.
assert "__CSRF_TOKEN_JSON__" in base
def test_cached_render_is_byte_identical_to_old_inline():
csrf = "test-csrf-abc123"
new = routes._render_index_shell_base().replace(
"__CSRF_TOKEN_JSON__", json.dumps(csrf)
)
assert new == _old_inline_render(csrf)
def test_csrf_token_varies_per_request():
a = routes._render_index_shell_base().replace(
"__CSRF_TOKEN_JSON__", json.dumps("AAA")
)
b = routes._render_index_shell_base().replace(
"__CSRF_TOKEN_JSON__", json.dumps("BBB")
)
assert json.dumps("AAA") in a
assert json.dumps("BBB") in b
assert a != b
def test_second_call_returns_cached_object():
# Warm the cache, then assert the identical object comes back (no re-read).
first = routes._render_index_shell_base()
second = routes._render_index_shell_base()
assert first is second
def test_cache_invalidates_on_mtime_change(tmp_path, monkeypatch):
# Point the module at a temp copy so we can mutate its mtime safely.
src = routes._INDEX_HTML_PATH.read_text(encoding="utf-8")
fake = tmp_path / "index.html"
fake.write_text(src, encoding="utf-8")
monkeypatch.setattr(routes, "_INDEX_HTML_PATH", fake)
# Reset the shared cache so this test is order-independent.
monkeypatch.setattr(routes, "_INDEX_SHELL_CACHE", {})
routes._render_index_shell_base()
sig_before = routes._INDEX_SHELL_CACHE["base"][0]
future = time.time() + 5
os.utime(fake, (future, future))
routes._render_index_shell_base()
sig_after = routes._INDEX_SHELL_CACHE["base"][0]
assert sig_after != sig_before
@@ -48,6 +48,7 @@ def test_sessions_list_reconciles_stale_stream_state_before_serializing(monkeypa
"session_id": "stale-session",
"title": "Stale Session",
"profile": "default",
"message_count": 1,
"active_stream_id": active_stream_id,
"is_streaming": is_streaming,
"updated_at": 1,
+14 -3
View File
@@ -30,6 +30,18 @@ def _extract_interim_handler(src):
pos = idx
def _visible_interim_flush_pos(fn):
attr_pos = fn.index("setAttribute('data-interim','1')")
candidates = [
"_flushPendingSegmentRender({force:true,skipAnchorProcessProse:true})",
"_flushPendingSegmentRender({force:true})",
]
positions = [fn.find(candidate, attr_pos) for candidate in candidates]
positions = [pos for pos in positions if pos != -1]
assert positions, "visible interim flush call not found after data-interim marker"
return min(positions)
class TestInterimCollapseHandlerStructure:
"""The interim_assistant handler must contain the collapse threshold and logic."""
@@ -93,7 +105,7 @@ class TestInterimCollapseHandlerStructure:
src = read("static/messages.js")
fn = _extract_interim_handler(src)
attr_pos = fn.index("setAttribute('data-interim','1')")
flush_pos = fn.rindex("_flushPendingSegmentRender({force:true})")
flush_pos = _visible_interim_flush_pos(fn)
assert attr_pos < flush_pos, (
"data-interim attribute must be set before _flushPendingSegmentRender "
"so the segment is marked before it is sealed"
@@ -102,7 +114,7 @@ class TestInterimCollapseHandlerStructure:
def test_collapse_after_flush_before_reset(self):
src = read("static/messages.js")
fn = _extract_interim_handler(src)
flush_pos = fn.index("_flushPendingSegmentRender({force:true})")
flush_pos = _visible_interim_flush_pos(fn)
collapse_pos = fn.index("INTERIM_COLLAPSE_THRESHOLD")
reset_pos = fn.index("_resetAssistantSegment()", collapse_pos)
assert flush_pos < collapse_pos < reset_pos, (
@@ -201,4 +213,3 @@ class TestInterimCollapseSurvivesLiveTurnRestore:
"toggle must carry data-threshold so the delegated handler can "
"recompute the collapse set without closure state after a restore."
)
@@ -43,6 +43,7 @@ def test_active_session_idle_reconcile_clears_stale_busy_and_inflight_state():
assert "!s.is_streaming" in helper_body, "server idle helper must require is_streaming=false"
assert "!s.active_stream_id" in helper_body, "server idle helper must require no active stream id"
assert "!s.pending_user_message" in helper_body, "server idle helper must require no pending user text"
assert "!s.pending_started_at" in helper_body, "server idle helper must require no pending turn marker"
assert "S.busy=false" in body, "stale local busy state must be cleared"
assert "S.activeStreamId=null" in body, "stale active stream id must be cleared"
assert "delete INFLIGHT[sid]" in body, "stale active-session inflight cache must be purged"
@@ -59,6 +60,41 @@ def test_active_session_idle_reconcile_clears_stale_busy_and_inflight_state():
)
def test_effective_streaming_ignores_stale_raw_active_stream_ids():
local_body = _function_body(SESSIONS_SRC, "function _isSessionLocallyStreaming(")
pending_body = _function_body(SESSIONS_SRC, "function _hasPendingUserMessageSignal(")
effective_body = _function_body(SESSIONS_SRC, "function _isSessionEffectivelyStreaming(")
assert "s.active_stream_id" not in effective_body
assert "_hasPendingUserMessageSignal(s)" in effective_body
assert "s.pending_started_at" not in effective_body
script = f"""
let S = {{ session: null, busy: false }};
function _isSessionLocallyStreaming(s) {{{local_body}}}
function _hasPendingUserMessageSignal(s) {{{pending_body}}}
function _isSessionEffectivelyStreaming(s) {{{effective_body}}}
const rows = {{
serverStreaming: _isSessionEffectivelyStreaming({{session_id:'child', is_streaming:true, active_stream_id:'stream-1'}}),
staleActiveStream: _isSessionEffectivelyStreaming({{session_id:'child', is_streaming:false, active_stream_id:'dead-stream'}}),
pendingUser: _isSessionEffectivelyStreaming({{session_id:'child', pending_user_message:'working'}}),
pendingFlag: _isSessionEffectivelyStreaming({{session_id:'child', has_pending_user_message:true}}),
pendingStartedOnly: _isSessionEffectivelyStreaming({{session_id:'child', pending_started_at:123}}),
idle: _isSessionEffectivelyStreaming({{session_id:'child'}}),
}};
console.log(JSON.stringify(rows));
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {
"serverStreaming": True,
"staleActiveStream": False,
"pendingUser": True,
"pendingFlag": True,
"pendingStartedOnly": False,
"idle": False,
}
def test_active_session_idle_reconcile_schedules_forced_transcript_reload():
helper_body = _function_body(SESSIONS_SRC, "function _scheduleActiveSessionIdleReload(")
+19 -2
View File
@@ -68,14 +68,31 @@ def test_live_anchor_worklog_rebuild_restores_snapshot_before_follow_settle():
body = _function_body(UI_JS, "renderLiveAnchorActivityScene")
capture_idx = body.index("const scrollSnapshot=_captureMessageScrollSnapshot();")
guard_idx = body.index("const scrollRebuildGuard=_prepareLiveAnchorScrollRebuildGuard(scrollSnapshot);")
remove_idx = body.index("blocks.querySelectorAll('[data-anchor-scene-owner=\"1\"],[data-anchor-scene-row=\"1\"]')")
restore_detail_idx = body.index("_restoreWorklogDetailDisclosureState(blocks, liveDisclosureState);")
dedupe_idx = body.index("_dedupeLiveProcessedWorklogAnchors(turn);")
move_status_idx = body.index("_moveLiveRunStatusToTurnEnd();")
restore_idx = body.index("_restoreMessageScrollSnapshotSameFrame(scrollSnapshot);")
settle_idx = body.index("if(typeof scrollIfPinned==='function') scrollIfPinned();")
release_idx = body.index("if(scrollRebuildGuard&&scrollRebuildGuard.release)")
settle_idx = body.index("if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned();")
assert capture_idx < remove_idx < restore_detail_idx < dedupe_idx < move_status_idx < restore_idx < settle_idx
assert capture_idx < guard_idx < remove_idx < restore_detail_idx < dedupe_idx < move_status_idx < restore_idx < release_idx < settle_idx
def test_live_anchor_worklog_rebuild_guards_height_for_unpinned_reader():
guard = _function_body(UI_JS, "_prepareLiveAnchorScrollRebuildGuard")
compact = _compact(guard)
assert "constbeforeBottomDistance=Math.max(0,messagesEl.scrollHeight-messagesEl.scrollTop-messagesEl.clientHeight);" in compact
assert "beforeBottomDistance>250&&(_messageUserUnpinned||_scrollPinned===false)" in compact
assert "scrollSnapshot.pinned=false;" in compact
assert "scrollSnapshot.userUnpinned=true;" in compact
assert "scrollSnapshot.bottom=beforeBottomDistance;" in compact
assert "_messageUserUnpinned=true;" in compact
assert "_scrollPinned=false;" in compact
assert "_nearBottomCount=0;" in compact
assert "msgInner.style.minHeight=`${guardHeight}px`;" in compact
def test_same_frame_snapshot_preserves_bottom_distance_and_unpinned_state():
@@ -107,6 +107,108 @@ assert.strictEqual(_programmaticScroll, false);
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_live_anchor_rebuild_guard_holds_height_and_marks_reader_unpinned():
"""Live anchor rebuilds must not let the scroll container collapse mid-read."""
script = f"""
const assert = require('assert');
let _messageUserUnpinned = true;
let _scrollPinned = false;
let _nearBottomCount = 2;
const messages = {{
scrollTop: 2000,
scrollHeight: 5000,
clientHeight: 600,
}};
const inner = {{ style: {{ minHeight: '' }}, dataset: {{}} }};
function $(id) {{
if (id === 'messages') return messages;
if (id === 'msgInner') return inner;
return null;
}}
{_function_body(UI_JS, "_prepareLiveAnchorScrollRebuildGuard")}
const snapshot = {{
top: 2000,
bottom: 0,
scrollHeight: 5000,
pinned: true,
userUnpinned: false,
}};
const guard = _prepareLiveAnchorScrollRebuildGuard(snapshot);
assert.strictEqual(guard.readerAwayFromBottom, true);
assert.strictEqual(snapshot.pinned, false);
assert.strictEqual(snapshot.userUnpinned, true);
assert.strictEqual(snapshot.bottom, 2400);
assert.strictEqual(_messageUserUnpinned, true);
assert.strictEqual(_scrollPinned, false);
assert.strictEqual(_nearBottomCount, 0);
assert.strictEqual(inner.style.minHeight, '5000px');
assert.strictEqual(inner.dataset.liveAnchorScrollGuardPreviousMinHeight, '');
messages.scrollHeight = 5200;
const nestedSnapshot = {{
top: 2000,
bottom: 0,
scrollHeight: 5200,
pinned: true,
userUnpinned: false,
}};
const nestedGuard = _prepareLiveAnchorScrollRebuildGuard(nestedSnapshot);
assert.strictEqual(nestedGuard.readerAwayFromBottom, true);
assert.strictEqual(inner.style.minHeight, '5200px');
assert.strictEqual(inner.dataset.liveAnchorScrollGuardPreviousMinHeight, '');
guard.release();
assert.strictEqual(inner.style.minHeight, '');
nestedGuard.release();
assert.strictEqual(inner.style.minHeight, '');
assert.strictEqual(Object.prototype.hasOwnProperty.call(inner.dataset, 'liveAnchorScrollGuardPreviousMinHeight'), false);
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_live_anchor_rebuild_guard_keeps_pinned_follower_pinned_on_large_growth():
"""A PINNED follower must NOT be reclassified as unpinned when a large live
render transiently pushes bottomDistance>250. Regression guard: the predicate
must require an explicit non-follow signal (_messageUserUnpinned / _scrollPinned
=== false), not a raw scrollTop>0, or pinned live streams stop auto-following."""
script = f"""
const assert = require('assert');
let _messageUserUnpinned = false;
let _scrollPinned = true;
let _nearBottomCount = 2;
const messages = {{
scrollTop: 4400,
scrollHeight: 5500,
clientHeight: 600,
}};
const inner = {{ style: {{ minHeight: '' }}, dataset: {{}} }};
function $(id) {{
if (id === 'messages') return messages;
if (id === 'msgInner') return inner;
return null;
}}
{_function_body(UI_JS, "_prepareLiveAnchorScrollRebuildGuard")}
const snapshot = {{
top: 4400,
bottom: 0,
scrollHeight: 5500,
pinned: true,
userUnpinned: false,
}};
const guard = _prepareLiveAnchorScrollRebuildGuard(snapshot);
// bottomDistance = 5500 - 4400 - 600 = 500 (>250), but the reader is pinned and
// has not manually unpinned -> must stay pinned, follow-scroll must NOT be skipped.
assert.strictEqual(guard.readerAwayFromBottom, false);
assert.strictEqual(guard.release, null);
assert.strictEqual(_messageUserUnpinned, false);
assert.strictEqual(_scrollPinned, true);
assert.strictEqual(snapshot.pinned, true);
assert.strictEqual(snapshot.userUnpinned, false);
assert.strictEqual(inner.style.minHeight, '');
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_restore_message_scroll_snapshot_keeps_intermediate_distance_state():
script = f"""
const assert = require('assert');
@@ -0,0 +1,53 @@
"""Static source assertions for issue #4346 virtual-scroll footer jitter fix."""
import pathlib
import re
ROOT = pathlib.Path(__file__).parent.parent
CSS = (ROOT / 'static' / 'style.css').read_text(encoding='utf-8')
JS = (ROOT / 'static' / 'ui.js').read_text(encoding='utf-8')
def test_css_vscroll_measuring_guard():
"""style.css suppresses opacity transitions on .msg-foot and .msg-actions
while .vscroll-measuring is present on the scroll container."""
assert 'vscroll-measuring' in CSS
guard_match = re.search(
r'(?m)^\.vscroll-measuring\s+\.msg-foot,\n'
r'^\.vscroll-measuring\s+\.msg-actions,\n'
r'^\.vscroll-measuring\s+\.msg-time\{transition:none !important;\}$',
CSS,
)
assert guard_match, \
"missing contiguous .vscroll-measuring transition:none !important guard block"
def test_js_compensate_adds_vscroll_measuring():
"""_compensateScrollForMeasurementDelta adds and removes the vscroll-measuring
class around the render callback."""
fn_match = re.search(
r'function _compensateScrollForMeasurementDelta\(renderFn\)\{(.+?)^(?=function )',
JS, re.DOTALL | re.MULTILINE
)
assert fn_match, "_compensateScrollForMeasurementDelta not found"
body = fn_match.group(1)
assert "classList.add('vscroll-measuring')" in body
assert "classList.remove('vscroll-measuring')" in body
def test_js_try_finally_guards_class_removal():
"""The classList.remove is inside the finally{} block, not after it."""
fn_match = re.search(
r'function _compensateScrollForMeasurementDelta\(renderFn\)\{(.+?)^(?=function )',
JS, re.DOTALL | re.MULTILINE
)
assert fn_match
body = fn_match.group(1)
try_idx = body.find('try{')
finally_idx = body.find('finally{')
remove_idx = body.find("classList.remove('vscroll-measuring')")
assert try_idx != -1, "no try block found in _compensateScrollForMeasurementDelta"
assert finally_idx != -1, "no finally block found in _compensateScrollForMeasurementDelta"
assert remove_idx != -1, "missing classList.remove('vscroll-measuring')"
assert try_idx < finally_idx < remove_idx, \
"classList.remove must remain in the finally{} cleanup path"
@@ -133,11 +133,17 @@ class TestSwitchWiring:
class TestSessionsWiring:
def test_skeleton_flag_cleared_on_real_render(self):
# renderSessionListFromCache clears the skeleton-active flag when it
# writes real rows (so a strand can't persist).
idx = SESSIONS.index("function renderSessionListFromCache(")
body = SESSIONS[idx: idx + 4000]
assert "_sessionListSkeletonActive=false" in body.replace(" ", "")
# The authoritative clear now happens in _applySessionListPayload()
# immediately before the real render, while renderSessionListFromCache()
# keeps the guard that blocks stale cached rows during the skeleton phase.
render_idx = SESSIONS.index("function renderSessionListFromCache(")
render_body = SESSIONS[render_idx: render_idx + 1200]
apply_idx = SESSIONS.index("function _applySessionListPayload(")
apply_body = SESSIONS[apply_idx: apply_idx + 4000]
assert "if(_sessionListSkeletonActive)return;" in render_body.replace(" ", "")
assert "_sessionListSkeletonActive = false;" in apply_body
assert "renderSessionListFromCache();" in apply_body
def test_builder_defines_groups_and_function(self):
assert "const _SESSION_SKELETON_GROUPS" in SESSIONS
@@ -0,0 +1,652 @@
"""Regression coverage for issue #4766: `/api/sessions` filters by active sidebar source."""
import io
import json
from pathlib import Path
import shutil
import subprocess
from urllib.parse import urlparse
import api.profiles as profiles
import api.routes as routes
import pytest
ROOT = Path(__file__).resolve().parents[1]
SESSIONS_JS = ROOT / "static" / "sessions.js"
NODE = shutil.which("node")
class _FakeHandler:
def __init__(self):
self.status = None
self.headers = {}
self.wfile = io.BytesIO()
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.headers[key] = value
def end_headers(self):
pass
def json_body(self):
return json.loads(self.wfile.getvalue().decode("utf-8"))
def _session_rows(
webui_count,
cli_count,
archived_webui_count=0,
archived_cli_count=0,
start=0,
):
rows = []
for index in range(webui_count):
rows.append(
{
"session_id": f"webui-{start + index}",
"title": "WebUI Session",
"profile": "default",
"archived": index < archived_webui_count,
"message_count": 1,
"updated_at": 1000 + index,
"last_message_at": 1000 + index,
"source": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_tag": "webui",
}
)
for index in range(cli_count):
rows.append(
{
"session_id": f"cli-{start + index + 10000}",
"title": "Imported CLI session",
"profile": "default",
"archived": index < archived_cli_count,
"message_count": 1,
"updated_at": 2000 + index,
"last_message_at": 2000 + index,
"source": "cli",
"raw_source": "cli",
"session_source": "cli",
"source_tag": "cli",
}
)
return rows
def _handle_sessions(url):
handler = _FakeHandler()
routes.handle_get(handler, urlparse(url))
return handler
def _extract_function(source_text, function_name):
marker = f"function {function_name}("
start = source_text.index(marker)
brace_start = source_text.index("{", start)
depth = 0
for index in range(brace_start, len(source_text)):
char = source_text[index]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return source_text[start : index + 1]
raise AssertionError(f"Could not extract {function_name}")
def _run_node(script):
proc = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=True)
return json.loads(proc.stdout)
@pytest.fixture(autouse=True)
def _clear_cache():
routes._session_list_cache_clear()
yield
routes._session_list_cache_clear()
def _install_common_monkeypatches(monkeypatch, rows):
enriched = []
row_ids = {str(row["session_id"]) for row in rows if row.get("session_id")}
monkeypatch.setattr(routes, "all_sessions", lambda diag=None: list(rows))
monkeypatch.setattr(routes, "_reconcile_stale_stream_state_for_session_rows", lambda _rows: False)
monkeypatch.setattr(routes, "_enrich_sidebar_lineage_metadata", lambda rows: enriched.append([r["session_id"] for r in rows]))
monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None, all_profiles=False: [])
monkeypatch.setattr(routes, "agent_session_rows_existing", lambda ids, profile=None: set(row_ids & {str(sid) for sid in ids}))
monkeypatch.setattr(routes, "load_settings", lambda: {"show_cli_sessions": True})
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
return enriched
def test_sidebar_source_webui_excludes_cli_rows(monkeypatch):
rows = _session_rows(webui_count=30, cli_count=20)
enriched = _install_common_monkeypatches(monkeypatch, rows)
handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui")
body = handler.json_body()
assert handler.status == 200
assert len(body["sessions"]) == 30
assert all(r["session_id"].startswith("webui-") for r in body["sessions"])
assert body["webui_session_count"] == 30
assert body["cli_session_count"] == 20
assert body["archived_count"] == 0
expected = {
row["session_id"] for row in rows
if not row["archived"] and row["session_id"].startswith("webui-")
}
assert set(enriched[0]) == expected
def test_sidebar_source_cli_excludes_webui_rows(monkeypatch):
rows = _session_rows(webui_count=30, cli_count=20)
_install_common_monkeypatches(monkeypatch, rows)
handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=cli")
body = handler.json_body()
assert handler.status == 200
assert len(body["sessions"]) == 20
assert all(r["session_id"].startswith("cli-") for r in body["sessions"])
assert body["webui_session_count"] == 30
assert body["cli_session_count"] == 20
def test_sidebar_source_omitted_returns_all_rows(monkeypatch):
rows = _session_rows(webui_count=30, cli_count=20)
_install_common_monkeypatches(monkeypatch, rows)
handler = _handle_sessions("http://example.com/api/sessions")
body = handler.json_body()
assert handler.status == 200
assert len(body["sessions"]) == 50
assert len([r for r in body["sessions"] if r["session_id"].startswith("webui-")]) == 30
assert len([r for r in body["sessions"] if r["session_id"].startswith("cli-")]) == 20
def test_sidebar_source_returns_cross_bucket_counts(monkeypatch):
rows = _session_rows(webui_count=30, cli_count=20, archived_webui_count=2, archived_cli_count=3)
_install_common_monkeypatches(monkeypatch, rows)
handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui&include_archived=1")
webui_rows = [r for r in rows if r["session_id"].startswith("webui-")]
cli_rows = [r for r in rows if r["session_id"].startswith("cli-")]
body = handler.json_body()
assert handler.status == 200
assert body["webui_session_count"] == len(webui_rows)
assert body["cli_session_count"] == len(cli_rows)
def test_sidebar_source_preserves_archived_counts(monkeypatch):
rows = _session_rows(webui_count=30, cli_count=20, archived_webui_count=2, archived_cli_count=3)
_install_common_monkeypatches(monkeypatch, rows)
handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui&include_archived=1")
body = handler.json_body()
assert handler.status == 200
assert body["archived_webui_count"] == 2
assert body["archived_cli_count"] == 3
assert body["archived_count"] == 5
assert len([r for r in body["sessions"] if r["archived"]]) == 2
def test_sidebar_source_varies_cache_key():
key_webui = routes._session_list_cache_key(
active_profile="default",
all_profiles=False,
show_cli_sessions=True,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
include_archived=False,
sidebar_source="webui",
)
key_cli = routes._session_list_cache_key(
active_profile="default",
all_profiles=False,
show_cli_sessions=True,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
include_archived=False,
sidebar_source="cli",
)
key_omitted = routes._session_list_cache_key(
active_profile="default",
all_profiles=False,
show_cli_sessions=True,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
include_archived=False,
sidebar_source=None,
)
assert key_webui != key_cli
assert key_webui != key_omitted
assert key_cli != key_omitted
def test_frontend_sends_sidebar_source_param():
src = SESSIONS_JS.read_text(encoding="utf-8")
assert "function _requestedSessionSidebarSource()" in src
assert "function _sessionListQueryString()" in src
assert "qs.set('sidebar_source', _requestedSessionSidebarSource());" in src
assert "_serverWebuiSessionCount" in src
assert "_serverCliSessionCount" in src
assert "function _sessionSourceTabCount(" in src
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_session_list_query_string_respects_sidebar_source_and_flags():
src = SESSIONS_JS.read_text(encoding="utf-8")
requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource")
exclude_hidden_fn = _extract_function(src, "_sessionListExcludeHiddenEnabled")
query_fn = _extract_function(src, "_sessionListQueryString")
script = f"""
global.window = {{ _showCliSessions: true }};
global._activeProject = null;
global._sessionSourceFilter = 'cli';
global._showAllProfiles = true;
global._showArchived = false;
{requested_source_fn}
{exclude_hidden_fn}
{query_fn}
const first = _sessionListQueryString();
window._showCliSessions = false;
global._showArchived = true;
const second = _sessionListQueryString();
global._activeProject = '__none__';
global.NO_PROJECT_FILTER = '__none__';
global._showAllProfiles = false;
global._showArchived = false;
const third = _sessionListQueryString();
console.log(JSON.stringify({{ first, second, third }}));
"""
body = _run_node(script)
assert body["first"] == "?sidebar_source=cli&exclude_hidden=1&all_profiles=1"
assert body["second"] == "?sidebar_source=webui&exclude_hidden=1&all_profiles=1&include_archived=1"
assert body["third"] == "?sidebar_source=webui&exclude_hidden=1"
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_session_source_switch_fetches_selected_bucket():
src = SESSIONS_JS.read_text(encoding="utf-8")
fn_source = _extract_function(src, "_setSessionSourceFilter")
script = f"""
const renderCalls = [];
global._sessionSourceFilter = 'webui';
global._activeProject = 'demo-project';
global._selectedSessions = new Set(['first', 'second']);
global._sessionSelectMode = true;
global.localStorage = {{
writes: [],
setItem(key, value) {{
this.writes.push([key, value]);
}},
}};
global.renderSessionListFromCache = () => {{
renderCalls.push('cache');
}};
global.renderSessionList = (opts) => {{
renderCalls.push(opts);
return Promise.resolve();
}};
{fn_source}
_setSessionSourceFilter('cli');
console.log(JSON.stringify({{
sourceFilter: global._sessionSourceFilter,
activeProject: global._activeProject,
selectedSize: global._selectedSessions.size,
sessionSelectMode: global._sessionSelectMode,
storageWrites: global.localStorage.writes,
renderCalls,
}}));
"""
body = _run_node(script)
assert body["sourceFilter"] == "cli"
assert body["activeProject"] is None
assert body["selectedSize"] == 0
assert body["sessionSelectMode"] is False
assert body["storageWrites"] == [["hermes-session-source-filter", "cli"]]
assert body["renderCalls"] == ["cache", {"deferWhileInteracting": False}]
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_apply_payload_and_tab_count_helpers_cover_old_and_new_payloads():
src = SESSIONS_JS.read_text(encoding="utf-8")
requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource")
exclude_hidden_fn = _extract_function(src, "_sessionListExcludeHiddenEnabled")
apply_fn = _extract_function(src, "_applySessionListPayload")
count_fn = _extract_function(src, "_sessionSourceTabCount")
clear_fn = _extract_function(src, "_clearSessionSourceTabCounts")
script = f"""
global.window = {{ _showCliSessions: true }};
global._otherProfileCount = 0;
global._archivedWebuiCount = 0;
global._archivedCliCount = 0;
global._serverWebuiSessionCount = null;
global._serverCliSessionCount = null;
global._serverTimeDelta = 0;
global._serverTz = null;
global._optimisticallyRemovedSessionIds = new Set();
global._allSessions = [];
global._allSessionsScope = null;
global._allProjects = [];
global._sessionListLoadError = null;
global._sessionListHasLoadedOnce = false;
global._sessionListFirstRenderAnimated = true;
global._sessionListSkeletonActive = true;
global._activeProject = null;
global.NO_PROJECT_FILTER = '__none__';
global._showAllProfiles = false;
global._sessionSourceFilter = 'webui';
global.S = {{ activeProfile: 'default' }};
global._reconcileActiveSessionIdleStateFromList = rows => rows;
global._mergeOptimisticFirstTurnSessions = rows => rows;
global._syncSessionAttentionSoundState = () => {{}};
global._pruneLineageReportCacheToVisibleSessions = () => {{}};
global._markPollingCompletionUnreadTransitions = () => {{}};
global._isSessionEffectivelyStreaming = () => false;
global.startStreamingPoll = () => {{}};
global.stopStreamingPoll = () => {{}};
global.ensureSessionTimeRefreshPoll = () => {{}};
global.ensureActiveSessionExternalRefreshPoll = () => {{}};
global.ensureSessionEventsSSE = () => {{}};
global.animateNextSessionListRefresh = () => {{}};
global.renderSessionListFromCache = () => {{}};
{clear_fn}
{count_fn}
{requested_source_fn}
{exclude_hidden_fn}
{apply_fn}
const sessions = [{{ session_id: 'webui-1' }}];
_applySessionListPayload({{ sessions, other_profile_count: 0, archived_count: 0, active_profile: 'default' }}, {{ projects: [] }});
const oldPayload = {{
webui: _sessionSourceTabCount('webui', 7, 3),
cli: _sessionSourceTabCount('cli', 7, 3),
}};
_clearSessionSourceTabCounts();
_applySessionListPayload({{
sessions,
other_profile_count: 0,
archived_count: 0,
active_profile: 'default',
webui_session_count: 11,
cli_session_count: 5,
}}, {{ projects: [] }});
const newPayload = {{
webui: _sessionSourceTabCount('webui', 7, 3),
cli: _sessionSourceTabCount('cli', 7, 3),
}};
console.log(JSON.stringify({{ oldPayload, newPayload }}));
"""
body = _run_node(script)
assert body["oldPayload"] == {"webui": 7, "cli": 3}
assert body["newPayload"] == {"webui": 11, "cli": 5}
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_source_filtered_cache_preserves_hidden_bucket_runtime_state():
src = SESSIONS_JS.read_text(encoding="utf-8")
is_cli_fn = _extract_function(src, "_isCliSession")
remember_source_fn = _extract_function(src, "_rememberSessionListSource")
remember_streaming_fn = _extract_function(src, "_rememberRenderedStreamingState")
remember_snapshot_fn = _extract_function(src, "_rememberRenderedSessionSnapshot")
purge_fn = _extract_function(src, "_purgeStaleInflightEntries")
mark_fn = _extract_function(src, "_markPollingCompletionUnreadTransitions")
script = f"""
global._allSessions = [{{
session_id: 'cli-1',
source_tag: 'cli',
raw_source: 'cli',
session_source: 'cli',
is_streaming: false,
message_count: 1,
last_message_at: 10,
}}];
global._allSessionsScope = {{ sidebarSource: 'cli' }};
global._sessionListSourceById = new Map([['webui-live', 'webui']]);
global._sessionStreamingById = new Map([['webui-live', true]]);
global._sessionListSnapshotById = new Map([['webui-live', {{ message_count: 1, last_message_at: 1 }}]]);
global._sendInProgress = false;
global._sendInProgressSid = null;
global.INFLIGHT = {{ 'webui-live': {{ lastAssistantText: 'working' }} }};
const cleared = [];
global.clearInflightState = sid => cleared.push(sid);
global._isSessionEffectivelyStreaming = s => Boolean(s.is_streaming);
global._getSessionObservedStreaming = () => ({{}});
global._hasPendingUserMessageSignal = () => false;
global._isSessionActivelyViewedForList = () => false;
global._markSessionCompletionUnread = () => {{}};
global._setSessionViewedCount = () => {{}};
global._rememberObservedStreamingSession = () => {{}};
global._forgetObservedStreamingSession = () => {{}};
{is_cli_fn}
{remember_source_fn}
{remember_streaming_fn}
{remember_snapshot_fn}
{purge_fn}
{mark_fn}
const cliStale = {{
session_id: 'cli-stale',
source_tag: 'cli',
raw_source: 'cli',
session_source: 'cli',
is_streaming: false,
message_count: 2,
last_message_at: 2,
}};
_rememberRenderedStreamingState(cliStale, true);
_rememberRenderedSessionSnapshot(cliStale);
INFLIGHT['cli-stale'] = {{ lastAssistantText: 'stale' }};
_purgeStaleInflightEntries();
_markPollingCompletionUnreadTransitions(global._allSessions);
console.log(JSON.stringify({{
inflightKeys: Object.keys(INFLIGHT),
cleared,
streamingKeys: Array.from(_sessionStreamingById.keys()).sort(),
snapshotKeys: Array.from(_sessionListSnapshotById.keys()).sort(),
sourceKeys: Array.from(_sessionListSourceById.keys()).sort(),
}}));
"""
body = _run_node(script)
assert body["inflightKeys"] == ["webui-live"]
assert body["cleared"] == ["cli-stale"]
assert "webui-live" in body["streamingKeys"]
assert "cli-stale" not in body["streamingKeys"]
assert "webui-live" in body["snapshotKeys"]
assert "cli-stale" not in body["snapshotKeys"]
assert "webui-live" in body["sourceKeys"]
assert "cli-stale" not in body["sourceKeys"]
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_sid_only_source_remembering_skips_scope_fallback():
src = SESSIONS_JS.read_text(encoding="utf-8")
is_cli_fn = _extract_function(src, "_isCliSession")
remember_source_fn = _extract_function(src, "_rememberSessionListSource")
script = f"""
global._allSessions = [];
global._allSessionsScope = {{ sidebarSource: 'cli' }};
global._sessionListSourceById = new Map();
{is_cli_fn}
{remember_source_fn}
_rememberSessionListSource(null, 'detached-sid', false);
console.log(JSON.stringify({{
hasDetached: _sessionListSourceById.has('detached-sid'),
remembered: Array.from(_sessionListSourceById.entries()),
}}));
"""
body = _run_node(script)
assert body["hasDetached"] is False
assert body["remembered"] == []
def test_session_list_response_omits_bucket_counts_when_missing(monkeypatch):
monkeypatch.setattr(routes, "_session_list_cache_overlay_runtime_rows", lambda rows: rows)
monkeypatch.setattr(routes, "_sidebar_session_response_item", lambda row: row)
body = routes._session_list_payload_to_response(
{
"sessions": [{"session_id": "webui-1", "title": "WebUI Session"}],
"cli_count": 0,
"archived_count": 0,
"archived_webui_count": 0,
"archived_cli_count": 0,
"include_archived": False,
"all_profiles": False,
"active_profile": "default",
"other_profile_count": 0,
}
)
assert "webui_session_count" not in body
assert "cli_session_count" not in body
assert body["sessions"][0]["session_id"] == "webui-1"
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_scope_mismatch_error_path_respects_sidebar_source():
src = SESSIONS_JS.read_text(encoding="utf-8")
purge_fn = _extract_function(src, "_purgeStaleInflightEntries")
clear_fn = _extract_function(src, "_clearSessionSourceTabCounts")
requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource")
exclude_hidden_fn = _extract_function(src, "_sessionListExcludeHiddenEnabled")
query_fn = _extract_function(src, "_sessionListQueryString")
refresh_fn = _extract_function(src, "_runRenderSessionListRefresh").replace(
"function _runRenderSessionListRefresh",
"async function _runRenderSessionListRefresh",
1,
)
script = f"""
global.window = {{ _showCliSessions: true }};
global._showAllProfiles = false;
global._showArchived = false;
global._sessionListHasLoadedOnce = true;
global._SESSION_LIST_BOOT_TIMEOUT_MS = 90000;
global._renderSessionListGen = 1;
global._profileSwitchListEmbargo = false;
global._pendingSessionListPayload = null;
global._allProjects = [];
global._contentSearchResults = ['stale'];
global._activeProject = null;
global.NO_PROJECT_FILTER = '__none__';
global.S = {{ activeProfile: 'default' }};
global.$ = () => ({{ value: '' }});
global._isSessionListUserInteracting = () => false;
global._schedulePendingSessionListApply = () => {{}};
global._showSessionListLoadError = error => {{
global._lastError = error.message;
}};
const renders = [];
const cleared = [];
global.renderSessionListFromCache = () => {{
_purgeStaleInflightEntries();
renders.push({{
sessions: Array.isArray(global._allSessions) ? global._allSessions.map(s => s.session_id) : null,
scope: global._allSessionsScope ? {{ ...global._allSessionsScope }} : null,
webui: global._serverWebuiSessionCount,
cli: global._serverCliSessionCount,
skeleton: global._sessionListSkeletonActive,
inflightKeys: Object.keys(global.INFLIGHT || {{}}).sort(),
}});
}};
global.api = () => Promise.reject(new Error('boom'));
global.clearInflightState = sid => cleared.push(sid);
{purge_fn}
{clear_fn}
{requested_source_fn}
{exclude_hidden_fn}
{query_fn}
{refresh_fn}
async function runCase(requestedSource, cachedSource) {{
global._sessionSourceFilter = requestedSource;
global._allSessions = [{{ session_id: cachedSource + '-1' }}];
global._allSessionsScope = {{
profile: 'default',
allProfiles: false,
sidebarSource: cachedSource,
excludeHidden: true,
}};
global._sessionListSourceById = new Map([['webui-live', 'webui']]);
global.INFLIGHT = {{ 'webui-live': {{ lastAssistantText: 'working' }} }};
cleared.length = 0;
global._serverWebuiSessionCount = 11;
global._serverCliSessionCount = 5;
global._sessionListSkeletonActive = true;
global._lastError = null;
renders.length = 0;
await _runRenderSessionListRefresh({{}}, 1);
return {{
sessions: Array.isArray(global._allSessions) ? global._allSessions.map(s => s.session_id) : null,
scope: global._allSessionsScope ? {{ ...global._allSessionsScope }} : null,
webui: global._serverWebuiSessionCount,
cli: global._serverCliSessionCount,
skeleton: global._sessionListSkeletonActive,
error: global._lastError,
cleared: [...cleared],
inflightKeys: Object.keys(global.INFLIGHT || {{}}).sort(),
render: renders[0] || null,
}};
}}
(async () => {{
const mismatch = await runCase('cli', 'webui');
const match = await runCase('webui', 'webui');
console.log(JSON.stringify({{ mismatch, match }}));
}})().catch(error => {{
console.error(error);
process.exit(1);
}});
"""
body = _run_node(script)
assert body["mismatch"]["sessions"] == []
assert body["mismatch"]["scope"] == {
"profile": "default",
"allProfiles": False,
"sidebarSource": "cli",
"excludeHidden": True,
}
assert body["mismatch"]["webui"] is None
assert body["mismatch"]["cli"] is None
assert body["mismatch"]["skeleton"] is False
assert body["mismatch"]["render"]["sessions"] == []
assert body["mismatch"]["inflightKeys"] == ["webui-live"]
assert body["mismatch"]["cleared"] == []
assert body["mismatch"]["render"]["inflightKeys"] == ["webui-live"]
assert body["match"]["sessions"] == ["webui-1"]
assert body["match"]["scope"] == {
"profile": "default",
"allProfiles": False,
"sidebarSource": "webui",
"excludeHidden": True,
}
assert body["match"]["webui"] == 11
assert body["match"]["cli"] == 5
assert body["match"]["render"]["sessions"] == ["webui-1"]
def test_payload_row_count_regression(monkeypatch):
rows = _session_rows(webui_count=30, cli_count=20)
_install_common_monkeypatches(monkeypatch, rows)
handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui")
body = handler.json_body()
assert handler.status == 200
assert len(body["sessions"]) == 30
@@ -0,0 +1,349 @@
"""Regression coverage for issue #4775: hide zero-message and default_hidden rows for default sidebar loads."""
import io
import json
from pathlib import Path
import shutil
import subprocess
from urllib.parse import urlparse
import api.profiles as profiles
import api.routes as routes
import pytest
ROOT = Path(__file__).resolve().parents[1]
SESSIONS_JS = ROOT / "static" / "sessions.js"
NODE = shutil.which("node")
class _FakeHandler:
def __init__(self):
self.status = None
self.headers = {}
self.wfile = io.BytesIO()
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.headers[key] = value
def end_headers(self):
pass
def json_body(self):
return json.loads(self.wfile.getvalue().decode("utf-8"))
def _session_rows():
return [
{
"session_id": "visible-with-message",
"title": "Visible message",
"profile": "default",
"archived": False,
"message_count": 3,
"updated_at": 1000,
"last_message_at": 1000,
"source": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_tag": "webui",
"default_hidden": False,
},
{
"session_id": "hidden-by-default",
"title": "Default hidden",
"profile": "default",
"archived": False,
"message_count": 1,
"updated_at": 900,
"last_message_at": 900,
"source": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_tag": "webui",
"default_hidden": True,
},
{
"session_id": "zero-message",
"title": "Plain zero message",
"profile": "default",
"archived": False,
"message_count": 0,
"updated_at": 800,
"last_message_at": 800,
"source": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_tag": "webui",
"default_hidden": False,
},
{
"session_id": "active-stream-row",
"title": "Streaming row",
"profile": "default",
"archived": False,
"message_count": 0,
"active_stream_id": "stream-active",
"updated_at": 700,
"last_message_at": 700,
"source": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_tag": "webui",
"default_hidden": False,
},
{
"session_id": "attention-row",
"title": "Attention row",
"profile": "default",
"archived": False,
"message_count": 0,
"updated_at": 600,
"last_message_at": 600,
"source": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_tag": "webui",
"default_hidden": False,
},
{
"session_id": "pending-user-row",
"title": "Pending user row",
"profile": "default",
"archived": False,
"message_count": 0,
"pending_user_message": "user typing",
"updated_at": 500,
"last_message_at": 500,
"source": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_tag": "webui",
"default_hidden": False,
},
{
"session_id": "pending-flag-row",
"title": "Pending flag row",
"profile": "default",
"archived": False,
"message_count": 0,
"has_pending_user_message": True,
"updated_at": 550,
"last_message_at": 550,
"source": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_tag": "webui",
"default_hidden": False,
},
]
def _handle_sessions(url):
handler = _FakeHandler()
routes.handle_get(handler, urlparse(url))
return handler
def _extract_function(source_text, function_name):
marker = f"function {function_name}("
start = source_text.index(marker)
brace_start = source_text.index("{", start)
depth = 0
for index in range(brace_start, len(source_text)):
char = source_text[index]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return source_text[start : index + 1]
raise AssertionError(f"Could not extract {function_name}")
def _run_node(script):
proc = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=True)
return json.loads(proc.stdout)
@pytest.fixture(autouse=True)
def _clear_cache():
routes._session_list_cache_clear()
yield
routes._session_list_cache_clear()
def _install_common_monkeypatches(monkeypatch, rows):
session_ids = {str(row["session_id"]) for row in rows if row.get("session_id")}
monkeypatch.setattr(routes, "all_sessions", lambda diag=None: list(rows))
monkeypatch.setattr(routes, "_reconcile_stale_stream_state_for_session_rows", lambda _rows: False)
monkeypatch.setattr(routes, "_enrich_sidebar_lineage_metadata", lambda _rows: None)
monkeypatch.setattr(
routes,
"_session_attention_summary",
lambda session_id: {"kind": "clarify", "count": 1}
if str(session_id) == "attention-row"
else None,
)
monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None, all_profiles=False: [])
monkeypatch.setattr(routes, "agent_session_rows_existing", lambda ids, profile=None: set(ids) & session_ids)
monkeypatch.setattr(routes, "load_settings", lambda: {"show_cli_sessions": True})
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
return rows
def test_default_sidebar_excludes_hidden_and_plain_zero_message_rows(monkeypatch):
rows = _session_rows()
_install_common_monkeypatches(monkeypatch, rows)
handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui&exclude_hidden=1")
body = handler.json_body()
assert handler.status == 200
assert [r["session_id"] for r in body["sessions"]] == [
"visible-with-message",
"active-stream-row",
"attention-row",
"pending-flag-row",
"pending-user-row",
]
def test_zero_message_rows_with_visibility_signals_survive(monkeypatch):
rows = _session_rows()
_install_common_monkeypatches(monkeypatch, rows)
handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui&exclude_hidden=1")
body = handler.json_body()
by_id = {row["session_id"]: row for row in body["sessions"]}
assert handler.status == 200
assert set(by_id) >= {
"active-stream-row",
"attention-row",
"pending-flag-row",
"pending-user-row",
}
assert by_id["active-stream-row"]["active_stream_id"] == "stream-active"
assert by_id["attention-row"]["attention"]["kind"] == "clarify"
assert by_id["pending-flag-row"]["has_pending_user_message"] is True
assert by_id["pending-user-row"]["title"] == "Pending user row"
def test_omit_exclude_hidden_still_returns_default_hidden_rows(monkeypatch):
rows = _session_rows()
_install_common_monkeypatches(monkeypatch, rows)
handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui")
body = handler.json_body()
assert handler.status == 200
session_ids = {row["session_id"] for row in body["sessions"]}
assert "hidden-by-default" in session_ids
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_default_and_unassigned_queries_send_exclude_hidden(monkeypatch):
src = SESSIONS_JS.read_text(encoding="utf-8")
requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource")
exclude_hidden_fn = _extract_function(src, "_sessionListExcludeHiddenEnabled")
project_filter_fn = _extract_function(src, "_setActiveProjectFilter")
query_fn = _extract_function(src, "_sessionListQueryString")
script = f"""
global.window = {{ _showCliSessions: false }};
global._showCliSessions = false;
global._showAllProfiles = false;
global._showArchived = false;
global._activeProject = null;
global.NO_PROJECT_FILTER = '__none__';
global.renderSessionListFromCache = () => {{}};
global.renderSessionList = () => Promise.resolve();
{requested_source_fn}
{exclude_hidden_fn}
{project_filter_fn}
{query_fn}
const default_query = _sessionListQueryString();
_setActiveProjectFilter('__none__');
const unassigned_query = _sessionListQueryString();
_setActiveProjectFilter('demo-project');
const named_project_query = _sessionListQueryString();
console.log(JSON.stringify({{ default_query, unassigned_query, named_project_query }}));
"""
body = _run_node(script)
assert body["default_query"] == "?sidebar_source=webui&exclude_hidden=1"
assert body["unassigned_query"] == "?sidebar_source=webui&exclude_hidden=1"
assert body["named_project_query"] == "?sidebar_source=webui"
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_project_filter_click_path_triggers_fresh_session_load():
src = SESSIONS_JS.read_text(encoding="utf-8")
project_filter_fn = _extract_function(src, "_setActiveProjectFilter")
script = f"""
const calls = [];
global.NO_PROJECT_FILTER = '__none__';
global._activeProject = null;
global.renderSessionListFromCache = () => {{
calls.push('cache');
}};
global.renderSessionList = (opts) => {{
calls.push(opts);
return Promise.resolve();
}};
{project_filter_fn}
_setActiveProjectFilter('demo-project');
_setActiveProjectFilter('__none__');
console.log(JSON.stringify({{
activeProject: global._activeProject,
calls,
}}));
"""
body = _run_node(script)
assert body["activeProject"] == "__none__"
assert body["calls"] == [
"cache",
{"deferWhileInteracting": False},
"cache",
{"deferWhileInteracting": False},
]
def test_cache_key_varies_for_exclude_hidden_and_visible_only():
key_without_filters = routes._session_list_cache_key(
active_profile="default",
all_profiles=False,
show_cli_sessions=True,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
include_archived=False,
sidebar_source="webui",
)
key_exclude_hidden = routes._session_list_cache_key(
active_profile="default",
all_profiles=False,
show_cli_sessions=True,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
include_archived=False,
exclude_hidden=True,
sidebar_source="webui",
)
key_visible_only = routes._session_list_cache_key(
active_profile="default",
all_profiles=False,
show_cli_sessions=True,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
include_archived=False,
visible_only=True,
sidebar_source="webui",
)
assert key_without_filters != key_exclude_hidden
assert key_without_filters != key_visible_only
assert key_exclude_hidden != key_visible_only
@@ -878,6 +878,7 @@ const container = {
set scrollTop(v){ scrollTopWasMutated = true; scrollTopValue = v; },
getBoundingClientRect(){ return {top: 0}; },
querySelector(){ return null; },
classList: { add(){}, remove(){} },
};
function $(id){ return id === 'messages' ? container : null; }
function _captureMessageViewportAnchor(){ return null; }
@@ -908,6 +909,7 @@ const container = {
get scrollTop(){ return scrollTopValue; },
set scrollTop(v){ scrollHistory.push(v); scrollTopValue = v; },
getBoundingClientRect(){ return {top: 50, bottom: 650}; },
classList: { add(){}, remove(){} },
querySelector(selector){
if(selector === '[data-msg-idx="42"]'){
// After render, the row moved from relative offset 100 to 150 (50px shift)
@@ -954,6 +956,7 @@ const container = {
get scrollTop(){ return scrollTopValue; },
set scrollTop(v){ scrollHistory.push(v); scrollTopValue = v; },
getBoundingClientRect(){ return {top: 50, bottom: 650}; },
classList: { add(){}, remove(){} },
querySelector(selector){
if(selector === '[data-msg-idx="42"]'){
// Row moved by only 1px, within tolerance
@@ -1002,6 +1005,7 @@ const container = {
scrollTopValue = v;
},
getBoundingClientRect(){ return {top: 50, bottom: 650}; },
classList: { add(){}, remove(){} },
querySelector(selector){
if(selector === '[data-msg-idx="42"]'){
return {
@@ -1,5 +1,6 @@
"""Regression checks for #856 background completion unread markers."""
import json
from pathlib import Path
@@ -24,6 +25,21 @@ def _sessions_function_block(name: str, next_name: str) -> str:
return SESSIONS_JS[start:end]
def _function_body(block: str) -> str:
brace = block.find("{")
assert brace != -1, "function opening brace not found"
depth = 0
for i in range(brace, len(block)):
ch = block[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return block[brace + 1 : i]
raise AssertionError("function closing brace not found")
def test_background_completion_unread_uses_explicit_marker_not_message_delta():
"""A background completion must stay unread even when message_count has no delta."""
assert "SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread'" in SESSIONS_JS
@@ -117,10 +133,10 @@ def test_polling_transition_marks_completion_unread_without_sse_done():
"_markPollingCompletionUnreadTransitions",
"newSession",
)
effective_block = _sessions_function_block(
effective_block = _function_body(_sessions_function_block(
"_isSessionEffectivelyStreaming",
"_markPollingCompletionUnreadTransitions",
)
))
render_idx = SESSIONS_JS.find("async function renderSessionList")
assert render_idx != -1, "renderSessionList not found"
refresh_idx = SESSIONS_JS.find("async function _runRenderSessionListRefresh")
@@ -134,7 +150,11 @@ def test_polling_transition_marks_completion_unread_without_sse_done():
assert "const _sessionStreamingById = new Map();" in SESSIONS_JS
assert "const wasStreaming = _sessionStreamingById.get(sid);" in transition_block
assert "const isStreaming = _isSessionEffectivelyStreaming(s);" in transition_block
assert "s.is_streaming || _isSessionLocallyStreaming(s)" in effective_block
assert "s.is_streaming" in effective_block
assert "s.active_stream_id" not in effective_block
assert "_hasPendingUserMessageSignal(s)" in effective_block
assert "s.pending_started_at" not in effective_block
assert "_isSessionLocallyStreaming(s)" in effective_block
assert "wasStreaming === true && !isStreaming" in transition_block, (
"polling fallback must only fire on an observed streaming -> stopped transition"
)
@@ -142,6 +162,102 @@ def test_polling_transition_marks_completion_unread_without_sse_done():
assert "_sessionStreamingById.set(sid, isStreaming);" in transition_block
assert "_applySessionListPayload(sessData,projData);" in refresh_block
assert "_markPollingCompletionUnreadTransitions(_allSessions);" in apply_block
assert "_allSessions.some(s => _isSessionEffectivelyStreaming(s))" in apply_block, (
"the streaming poll fallback must stay active for the same server-confirmed "
"streaming states that can render a sidebar spinner"
)
def test_polling_transition_ignores_stale_active_stream_id_without_server_streaming():
local_body = _function_body(_sessions_function_block(
"_isSessionLocallyStreaming",
"_isSessionEffectivelyStreaming",
))
effective_body = _function_body(_sessions_function_block(
"_isSessionEffectivelyStreaming",
"_markPollingCompletionUnreadTransitions",
))
transition_body = _function_body(_sessions_function_block(
"_markPollingCompletionUnreadTransitions",
"newSession",
))
script = f"""
let S = {{ session: null, busy: false }};
const unread = [];
const _sessionStreamingById = new Map([['stale', true]]);
const _sessionListSnapshotById = new Map();
function _isSessionLocallyStreaming(s) {{{local_body}}}
function _hasPendingUserMessageSignal(s) {{ return !!(s && (s.pending_user_message || s.has_pending_user_message)); }}
function _isSessionEffectivelyStreaming(s) {{{effective_body}}}
function _hasSessionCompletionUnread() {{ return false; }}
function _markSessionCompletionUnread(sid) {{ unread.push(sid); }}
function _isSessionActivelyViewedForList() {{ return false; }}
function _rememberObservedStreamingSession() {{}}
function _forgetObservedStreamingSession() {{}}
function _getSessionObservedStreaming() {{ return {{}}; }}
function _markPollingCompletionUnreadTransitions(sessions) {{{transition_body}}}
_markPollingCompletionUnreadTransitions([{{
session_id:'stale',
is_streaming:false,
active_stream_id:'dead-stream',
message_count:5,
last_message_at:10,
updated_at:10,
}}]);
console.log(JSON.stringify({{unread, observed:_sessionStreamingById.get('stale')}}));
"""
import subprocess
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {"unread": [], "observed": False}
# The observed-streaming marker is persisted across reloads, unlike in-memory snapshots.
def test_polling_transition_marks_persisted_observed_stream_after_reload():
local_body = _function_body(_sessions_function_block(
"_isSessionLocallyStreaming",
"_isSessionEffectivelyStreaming",
))
effective_body = _function_body(_sessions_function_block(
"_isSessionEffectivelyStreaming",
"_markPollingCompletionUnreadTransitions",
))
transition_body = _function_body(_sessions_function_block(
"_markPollingCompletionUnreadTransitions",
"newSession",
))
script = f"""
let S = {{ session: null, busy: false }};
const unread = [];
const _sessionStreamingById = new Map();
const _sessionListSnapshotById = new Map();
function _isSessionLocallyStreaming(s) {{{local_body}}}
function _hasPendingUserMessageSignal(s) {{ return !!(s && (s.pending_user_message || s.has_pending_user_message)); }}
function _isSessionEffectivelyStreaming(s) {{{effective_body}}}
function _hasSessionCompletionUnread() {{ return false; }}
function _markSessionCompletionUnread(sid) {{ unread.push(sid); }}
function _isSessionActivelyViewedForList() {{ return false; }}
let observed = {{
done: {{message_count: 5, last_message_at: 10}}
}};
function _rememberObservedStreamingSession() {{}}
function _forgetObservedStreamingSession(sid) {{ delete observed[sid]; }}
function _getSessionObservedStreaming() {{ return observed; }}
function _markPollingCompletionUnreadTransitions(sessions) {{{transition_body}}}
_markPollingCompletionUnreadTransitions([{{
session_id:'done',
is_streaming:false,
active_stream_id:null,
message_count:5,
last_message_at:10,
updated_at:10,
}}]);
console.log(JSON.stringify({{unread, observed, streaming:_sessionStreamingById.get('done')}}));
"""
import subprocess
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {"unread": ["done"], "observed": {}, "streaming": False}
def test_polling_transition_does_not_mark_historical_first_render():
@@ -187,17 +303,21 @@ def test_polling_transition_tracks_the_same_effective_streaming_state_as_sidebar
"_isSessionLocallyStreaming",
"_isSessionEffectivelyStreaming",
)
effective_block = _sessions_function_block(
effective_block = _function_body(_sessions_function_block(
"_isSessionEffectivelyStreaming",
"_markPollingCompletionUnreadTransitions",
)
))
render_idx = SESSIONS_JS.find("function _renderOneSession")
assert render_idx != -1, "_renderOneSession not found"
render_block = SESSIONS_JS[render_idx:SESSIONS_JS.find("const hasUnread=", render_idx)]
assert "isActive && Boolean(S.busy)" in local_block
assert "INFLIGHT && INFLIGHT[s.session_id]" not in local_block
assert "s.is_streaming || _isSessionLocallyStreaming(s)" in effective_block
assert "s.is_streaming" in effective_block
assert "s.active_stream_id" not in effective_block
assert "_hasPendingUserMessageSignal(s)" in effective_block
assert "s.pending_started_at" not in effective_block
assert "_isSessionLocallyStreaming(s)" in effective_block
assert "const ownStreaming=_isSessionEffectivelyStreaming(s)" in render_block, (
"the row spinner and polling completion transition must use the same "
"effective streaming source, including local INFLIGHT-only streams"
+13 -2
View File
@@ -27,20 +27,31 @@ def test_interim_reasoning_echo_cleans_live_and_anchor_thinking():
assert "const reasoningEcho=!!(d&&d.reasoning_echo);" in body
assert "if(reasoningEcho) _stripLiveReasoningEcho(visible);" in body
assert "function _stripAnchorReasoningEcho(visible)" in MESSAGES
assert "function _removeLiveReasoningEchoRows(visible)" in MESSAGES
assert "events.splice(i,1);" in MESSAGES
assert '.agent-activity-thinking[data-anchor-scene-row="1"]' in MESSAGES
assert "_removeLiveReasoningEchoRows(visible)" in MESSAGES
assert "reasoningText=durable.text;" in MESSAGES
assert "liveReasoningText=live.text;" in MESSAGES
def test_interim_anchor_render_runs_after_legacy_segment_flush():
def test_interim_anchor_render_runs_after_legacy_segment_flush_without_duplicate_process_row():
body = _interim_listener_body()
flush_idx = body.index("_flushPendingSegmentRender({force:true});")
flush_idx = body.index("_flushPendingSegmentRender({force:true,skipAnchorProcessProse:true});")
anchor_idx = body.index("_applyToAnchor('interim_assistant',d,e);")
flush_fn_start = MESSAGES.index("function _flushPendingSegmentRender")
flush_fn = MESSAGES[flush_fn_start : MESSAGES.index("function _resetAssistantSegment", flush_fn_start)]
assert "const skipAnchorProcessProse=!!(options&&options.skipAnchorProcessProse);" in flush_fn
assert "if(!skipAnchorProcessProse) _upsertAnchorProcessProse(displayText,{sealed:force});" in flush_fn
assert flush_idx < anchor_idx, (
"Anchor live scene must render after the legacy interim segment is flushed, "
"so renderLiveAnchorActivityScene can hide that source segment immediately."
)
assert "_flushPendingSegmentRender({force:true});" in body, (
"already_streamed interim updates must still flush the token-owned prose row."
)
def test_live_anchor_scene_hides_legacy_live_assistant_sources():
@@ -338,12 +338,78 @@ def test_scene_renderer_coalesces_row_updates_and_renders_in_scene_order():
render = _function_body(UI_JS, "_renderAnchorSceneRowsIntoWorklog")
live = _function_body(UI_JS, "renderLiveAnchorActivityScene")
assert "const live=!settled" in rows
assert "const liveProseTextKeys=new Map()" in rows
assert "if(textKey&&liveProseTextKeys.has(textKey)) continue;" in rows
assert "byKey.set(key,out.length)" in rows
assert "out[index]=row.role==='tool'?_anchorSceneMergeToolRows(out[index],row):row" in rows
assert "for(const row of rows)" in render
assert "_anchorSceneNodeForRow(row,opts)" in render
assert "blocks.querySelectorAll('[data-live-assistant=\"1\"]')" in live
assert "assistant-segment-worklog-source" in live
assert "el.hidden=true" in live
@pytest.mark.skipif(NODE is None, reason="node is required for anchor row normalization tests")
def test_live_anchor_scene_dedupes_exact_duplicate_process_prose_only_live():
script = f"""
const fs = require('fs');
const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "ui.js"))}, 'utf8');
function extractFunc(name){{
const start = src.indexOf('function ' + name);
if(start === -1) throw new Error(name + ' not found');
const params = src.indexOf('(', start);
let depth = 0, close = -1;
for(let i=params; i<src.length; i++){{
if(src[i] === '(') depth++;
else if(src[i] === ')'){{
depth--;
if(depth === 0){{ close = i; break; }}
}}
}}
const brace = src.indexOf('{{', close);
depth = 0;
for(let i=brace; i<src.length; i++){{
if(src[i] === '{{') depth++;
else if(src[i] === '}}'){{
depth--;
if(depth === 0) return src.slice(start, i + 1);
}}
}}
throw new Error(name + ' body did not close');
}}
function _anchorSceneToolRowLogicalKey(){{ return ''; }}
function _anchorSceneMergeToolRows(a,b){{ return b; }}
function _anchorSceneIsSettledSuccessfulCompression(){{ return false; }}
eval(extractFunc('_anchorSceneRowsForRendering'));
const scene = {{
activity_rows: [
{{role:'prose', local_id:'reasoning:291', text:'same process prose'}},
{{role:'prose', local_id:'interim:293', text:' same\\nprocess prose '}},
{{role:'thinking', local_id:'thinking:1', text:'same process prose'}},
{{role:'prose', local_id:'process:294', text:'new process prose'}}
]
}};
const liveRows = _anchorSceneRowsForRendering(scene, {{settled:false}});
const settledRows = _anchorSceneRowsForRendering(scene, {{settled:true}});
console.log(JSON.stringify({{
live: liveRows.map(row => row.role + ':' + row.text.replace(/\\s+/g, ' ').trim()),
settled: settledRows.map(row => row.role + ':' + row.text.replace(/\\s+/g, ' ').trim())
}}));
"""
result = _run_node_script(script)
assert result["live"] == [
"prose:same process prose",
"thinking:same process prose",
"prose:new process prose",
]
assert result["settled"] == [
"prose:same process prose",
"prose:same process prose",
"thinking:same process prose",
"prose:new process prose",
]
def test_live_anchor_scene_removes_legacy_interim_collapse_toggle():
@@ -752,6 +818,7 @@ def test_settled_anchor_scene_final_answer_does_not_fold_into_worklog_source():
"if(m._activityBurstId!==undefined||m._liveSegmentSeq!==undefined) return true;"
)
assert "seg.classList.add('assistant-segment-worklog-source')" in render
assert "seg.hidden=true" in render
assert "_renderSettledAnchorSceneForMessage(msg, seg, rawIdx)" in render
@@ -763,6 +830,7 @@ def test_settled_anchor_scene_hides_prior_process_segments_not_final_answer():
assert "idx<rawIdx" in settled
assert "node.classList.add('assistant-segment-worklog-source')" in settled
assert "node.setAttribute('aria-hidden','true')" in settled
assert "node.hidden=true" in settled
assert "turnDuration:message._turnDuration!==undefined&&message._turnDuration!==null?message._turnDuration:scene.turn_duration" in settled
assert "turnDuration:opts&&opts.turnDuration" in group
assert "data-turn-duration" in group
+17 -6
View File
@@ -344,13 +344,24 @@ class TestIndexHtmlIntegration:
def test_index_route_url_encodes_asset_version(self):
src = ROUTES.read_text(encoding="utf-8")
idx = src.find('parsed.path in ("/", "/index.html")')
if idx == -1:
idx = src.find('parsed.path.startswith("/session/")')
assert idx != -1, "routes.py must handle /, /index.html, and /session/<id>"
block = src[idx:idx + 800]
# #4774 moved the app-shell render (incl. the version-token substitution)
# out of the route handler and into the cached `_render_index_shell_base()`
# helper. The security property — the cache-busting version token is
# URL-encoded before it's injected into script src / SW registration — must
# still hold, so assert it in whichever location renders the shell. Check the
# shell-render helper first (its current home), then fall back to the route
# handler block for older layouts.
helper_idx = src.find("def _render_index_shell_base")
if helper_idx != -1:
block = src[helper_idx:helper_idx + 1200]
else:
idx = src.find('parsed.path in ("/", "/index.html")')
if idx == -1:
idx = src.find('parsed.path.startswith("/session/")')
assert idx != -1, "routes.py must handle /, /index.html, and /session/<id>"
block = src[idx:idx + 800]
assert "quote(WEBUI_VERSION, safe=\"\")" in block, (
"index route must URL-encode the cache-busting version token before "
"the app-shell render must URL-encode the cache-busting version token before "
"injecting it into script src attributes and service worker registration"
)
+20
View File
@@ -42,6 +42,24 @@ def make_session(created_list):
return sid
def _make_session_visible(sid):
from api.models import Session
from tests.conftest import TEST_WORKSPACE
session = Session(
session_id=sid,
title="regression-test-delete-R8",
workspace=str(TEST_WORKSPACE),
model="test",
created_at=time.time(),
updated_at=time.time(),
profile="default",
messages=[{"role": "user", "content": "visible row", "timestamp": time.time()}],
tool_calls=[],
)
session.save(touch_updated_at=False)
def _make_auth_json_with_credential_pool(
provider_id: str, pool_entries: list[dict], tmp_dir: pathlib.Path
) -> pathlib.Path:
@@ -296,6 +314,8 @@ def test_deleted_session_does_not_appear_in_list(cleanup_test_sessions):
d, _ = post("/api/session/new", {})
sid = d["session"]["session_id"]
post("/api/session/rename", {"session_id": sid, "title": "regression-test-delete-R8"})
_make_session_visible(sid)
post("/api/session/rename", {"session_id": sid, "title": "regression-test-delete-R8"})
# Verify it appears
sessions, _ = get("/api/sessions")
+5 -1
View File
@@ -52,9 +52,13 @@ def test_delete_action_repaints_sidebar_before_loading_remaining_sessions():
api_call = "const deleteRequest=api('/api/session/delete'"
optimistic = "_optimisticallyRemoveSessionFromList(sid);"
remaining_fetch = "const remaining=await api('/api/sessions');"
remaining_fetch = "const remaining=await api('/api/sessions'+_sessionListQueryString());"
full_refresh = "await renderSessionList();"
assert optimistic in body
assert body.index(api_call) < body.index(optimistic) < body.index(full_refresh)
assert body.index(optimistic) < body.index(remaining_fetch)
def test_batch_delete_remaining_session_fetch_uses_sidebar_query_string():
"""Batch delete must reload the default sidebar through the same query builder."""
assert SESSIONS_JS.count("const remaining=await api('/api/sessions'+_sessionListQueryString());") >= 2
@@ -338,6 +338,7 @@ def test_sessions_endpoint_suppresses_duplicate_webui_state_projection(monkeypat
"session_id": "visible_tip",
"title": "Long Conversation",
"profile": "default",
"message_count": 1,
"updated_at": 20,
"last_message_at": 20,
"source_tag": "webui",
@@ -362,6 +363,7 @@ def test_sessions_endpoint_suppresses_duplicate_webui_state_projection(monkeypat
"session_id": "telegram_tip",
"title": "External Thread",
"profile": "default",
"message_count": 1,
"updated_at": 10,
"last_message_at": 10,
"source_tag": "telegram",
+305 -3
View File
@@ -730,7 +730,7 @@ console.log(JSON.stringify(rows));
assert "_child_sessions" not in rows[0]
def test_nested_fork_keeps_parent_timestamp_for_sorting():
def test_nested_fork_bubbles_latest_child_timestamp_for_sorting():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = f"""
const src = {js!r};
@@ -755,11 +755,313 @@ eval(extractFunc('_attachChildSessionsToSidebarRows'));
const parent = {{session_id:'parent', title:'Parent', updated_at:10, last_message_at:10}};
const fork = {{session_id:'fork1', title:'Fork', session_source:'fork', parent_session_id:'parent', updated_at:20, last_message_at:20}};
const rows = _attachChildSessionsToSidebarRows([parent, fork], [parent, fork]);
console.log(JSON.stringify({{row: rows[0], timestampMs: _sessionTimestampMs(rows[0])}}));
"""
result = json.loads(_run_node(source))
row = result["row"]
assert row["session_id"] == "parent"
# Keep the parent row's persisted timestamp intact, but use newest attached
# child/subagent activity for sidebar sorting/date grouping.
assert row["last_message_at"] == 10
assert row["_child_session_latest_at"] == 20
assert row["_sidebar_activity_at"] == 20
assert result["timestampMs"] == 20_000
def test_hidden_archived_lineage_child_bubbles_running_state_and_activity_to_visible_parent():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = f"""
const src = {js!r};
function extractFunc(name) {{
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
const start = src.search(re);
if (start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{{', start);
let depth = 1; i++;
while (depth > 0 && i < src.length) {{
if (src[i] === '{{') depth++;
else if (src[i] === '}}') depth--;
i++;
}}
return src.slice(start, i);
}}
function _isSessionEffectivelyStreaming(session) {{
return !!(session && (session.is_streaming || session.active_stream_id));
}}
function _hasUnreadForSession(session) {{
return !!(session && session.has_unread);
}}
eval(extractFunc('_sessionTimestampMs'));
eval(extractFunc('_isChildSession'));
eval(extractFunc('_isForkWithResolvableParent'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
const visibleTip = {{
session_id:'visible-tip',
title:'Visible lineage row',
parent_session_id:'parent-root',
session_source:'webui',
_lineage_root_id:'parent-root',
updated_at:10,
last_message_at:10,
}};
const archivedTip = {{
session_id:'archived-tip',
title:'Archived running tip',
parent_session_id:'parent-root',
session_source:'webui',
archived:true,
_lineage_root_id:'parent-root',
is_streaming:true,
active_stream_id:'stream-1',
updated_at:30,
last_message_at:30,
}};
const rows = _attachChildSessionsToSidebarRows([visibleTip], [visibleTip], [visibleTip, archivedTip]);
console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _sessionTimestampMs(rows[0])}}));
"""
result = json.loads(_run_node(source))
row = result["row"]
assert result["count"] == 1
assert row["session_id"] == "visible-tip"
assert "_child_sessions" not in row
assert row["_child_session_streaming"] is True
assert row["_child_session_latest_at"] == 30
assert row["_sidebar_activity_at"] == 30
assert result["timestampMs"] == 30_000
def test_archived_lineage_child_without_root_id_falls_back_to_parent_for_bubbling():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = f"""
const src = {js!r};
function extractFunc(name) {{
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
const start = src.search(re);
if (start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{{', start);
let depth = 1; i++;
while (depth > 0 && i < src.length) {{
if (src[i] === '{{') depth++;
else if (src[i] === '}}') depth--;
i++;
}}
return src.slice(start, i);
}}
function _isSessionEffectivelyStreaming(session) {{
return !!(session && (session.is_streaming || session.active_stream_id));
}}
function _hasUnreadForSession(session) {{
return !!(session && session.has_unread);
}}
eval(extractFunc('_sessionTimestampMs'));
eval(extractFunc('_isChildSession'));
eval(extractFunc('_isForkWithResolvableParent'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
const visibleTip = {{
session_id:'visible-tip',
title:'Visible lineage row',
parent_session_id:'parent-root',
session_source:'webui',
_lineage_root_id:'parent-root',
updated_at:10,
last_message_at:10,
}};
const archivedTip = {{
session_id:'archived-tip',
title:'Archived running tip',
parent_session_id:'parent-root',
session_source:'webui',
archived:true,
is_streaming:true,
active_stream_id:'stream-1',
updated_at:30,
last_message_at:30,
}};
const rows = _attachChildSessionsToSidebarRows([visibleTip], [visibleTip], [visibleTip, archivedTip]);
console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _sessionTimestampMs(rows[0])}}));
"""
result = json.loads(_run_node(source))
row = result["row"]
assert result["count"] == 1
assert row["session_id"] == "visible-tip"
assert "_child_sessions" not in row
assert row["_child_session_streaming"] is True
assert row["_child_session_latest_at"] == 30
assert row["_sidebar_activity_at"] == 30
assert result["timestampMs"] == 30_000
def test_non_archived_reference_only_lineage_row_does_not_bubble_to_visible_parent():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = f"""
const src = {js!r};
function extractFunc(name) {{
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
const start = src.search(re);
if (start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{{', start);
let depth = 1; i++;
while (depth > 0 && i < src.length) {{
if (src[i] === '{{') depth++;
else if (src[i] === '}}') depth--;
i++;
}}
return src.slice(start, i);
}}
function _isSessionEffectivelyStreaming(session) {{
return !!(session && (session.is_streaming || session.active_stream_id));
}}
function _hasUnreadForSession(session) {{
return !!(session && session.has_unread);
}}
eval(extractFunc('_sessionTimestampMs'));
eval(extractFunc('_isChildSession'));
eval(extractFunc('_isForkWithResolvableParent'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
const visibleTip = {{
session_id:'visible-tip',
title:'Visible lineage row',
parent_session_id:'parent-root',
session_source:'webui',
_lineage_root_id:'parent-root',
updated_at:10,
last_message_at:10,
}};
const filteredReferenceOnly = {{
session_id:'filtered-tip',
title:'Filtered non-archived tip',
parent_session_id:'parent-root',
session_source:'webui',
_lineage_root_id:'parent-root',
archived:false,
is_streaming:true,
active_stream_id:'stream-1',
updated_at:30,
last_message_at:30,
}};
const rows = _attachChildSessionsToSidebarRows([visibleTip], [visibleTip], [visibleTip, filteredReferenceOnly]);
console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _sessionTimestampMs(rows[0])}}));
"""
result = json.loads(_run_node(source))
row = result["row"]
assert result["count"] == 1
assert row["session_id"] == "visible-tip"
assert "_child_sessions" not in row
assert "_child_session_streaming" not in row
assert "_child_session_latest_at" not in row
assert "_sidebar_activity_at" not in row
assert result["timestampMs"] == 10_000
def test_stale_active_stream_id_on_hidden_archived_child_does_not_bubble_streaming_state():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = f"""
const src = {js!r};
function extractFunc(name) {{
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
const start = src.search(re);
if (start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{{', start);
let depth = 1; i++;
while (depth > 0 && i < src.length) {{
if (src[i] === '{{') depth++;
else if (src[i] === '}}') depth--;
i++;
}}
return src.slice(start, i);
}}
function _isSessionEffectivelyStreaming(session) {{
return !!(session && (session.is_streaming || session.pending_user_message || session.has_pending_user_message));
}}
function _hasUnreadForSession(session) {{
return !!(session && session.has_unread);
}}
eval(extractFunc('_sessionTimestampMs'));
eval(extractFunc('_isChildSession'));
eval(extractFunc('_isForkWithResolvableParent'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
const visibleTip = {{
session_id:'visible-tip',
title:'Visible lineage row',
parent_session_id:'parent-root',
session_source:'webui',
_lineage_root_id:'parent-root',
updated_at:10,
last_message_at:10,
}};
const archivedTip = {{
session_id:'archived-tip',
title:'Archived stale tip',
parent_session_id:'parent-root',
session_source:'webui',
archived:true,
_lineage_root_id:'parent-root',
is_streaming:false,
active_stream_id:'dead-stream',
updated_at:30,
last_message_at:30,
}};
const rows = _attachChildSessionsToSidebarRows([visibleTip], [visibleTip], [visibleTip, archivedTip]);
console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _sessionTimestampMs(rows[0])}}));
"""
result = json.loads(_run_node(source))
row = result["row"]
assert result["count"] == 1
assert row["session_id"] == "visible-tip"
assert "_child_sessions" not in row
assert "_child_session_streaming" not in row
assert row["_child_session_latest_at"] == 30
assert row["_sidebar_activity_at"] == 30
assert result["timestampMs"] == 30_000
def test_collapsed_lineage_segments_do_not_render_as_child_sessions():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = f"""
const src = {js!r};
function extractFunc(name) {{
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
const start = src.search(re);
if (start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{{', start);
let depth = 1; i++;
while (depth > 0 && i < src.length) {{
if (src[i] === '{{') depth++;
else if (src[i] === '}}') depth--;
i++;
}}
return src.slice(start, i);
}}
function _isSessionEffectivelyStreaming(session) {{
return !!(session && (session.is_streaming || session.active_stream_id));
}}
function _hasUnreadForSession(session) {{
return !!(session && session.has_unread);
}}
eval(extractFunc('_sessionTimestampMs'));
eval(extractFunc('_isChildSession'));
eval(extractFunc('_isForkWithResolvableParent'));
eval(extractFunc('_sessionLineageKey'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_collapseSessionLineageForSidebar'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
eval(extractFunc('_renderSidebarRowsFromRawSessions'));
const root = {{session_id:'root', title:'Root', updated_at:10, last_message_at:10, _lineage_root_id:'root', _lineage_tip_id:'root'}};
const mid = {{session_id:'mid', title:'Middle', parent_session_id:'root', updated_at:20, last_message_at:20, _lineage_root_id:'root', _lineage_tip_id:'mid'}};
const tip = {{session_id:'tip', title:'Tip', parent_session_id:'root', updated_at:30, last_message_at:30, _lineage_root_id:'root', _lineage_tip_id:'tip'}};
const rows = _renderSidebarRowsFromRawSessions([root, mid, tip], [root, mid, tip]);
console.log(JSON.stringify(rows));
"""
rows = json.loads(_run_node(source))
assert rows[0]["session_id"] == "parent"
assert rows[0]["last_message_at"] == 10
assert [row["session_id"] for row in rows] == ["tip"]
assert rows[0]["_lineage_collapsed_count"] == 3
assert [seg["session_id"] for seg in rows[0]["_lineage_segments"]] == ["tip", "mid", "root"]
assert "_child_sessions" not in rows[0]
assert "_child_session_count" not in rows[0]
def test_nested_fork_bubbles_parent_attention_state():
+1 -1
View File
@@ -34,7 +34,7 @@ def _write_session_with_secret_title():
created_at=now,
updated_at=now,
profile="default",
messages=[],
messages=[{"role": "user", "content": "visible row", "timestamp": now}],
tool_calls=[],
)
# Save through the model layer so the sidebar index is updated just like a
+7 -1
View File
@@ -31,6 +31,7 @@ let _allSessions = [
];
let _sessionStreamingById = new Map([['old', true], ['new', true]]);
let _sessionListSnapshotById = new Map([['old', {{message_count: 10}}]]);
let _sessionListSourceById = new Map([['old', 'webui'], ['new', 'webui']]);
function _forgetObservedStreamingSession(_sid) {{}}
function renderSessionListFromCache() {{}}
{fn_src}
@@ -40,7 +41,11 @@ _markSessionCompletedInList({{
message_count: 3,
parent_session_id: 'old',
}}, 'old');
console.log(JSON.stringify({{rows: _allSessions, oldStreaming: _sessionStreamingById.has('old')}}));
console.log(JSON.stringify({{
rows: _allSessions,
oldStreaming: _sessionStreamingById.has('old'),
oldSourceTracked: _sessionListSourceById.has('old'),
}}));
"""
result = subprocess.run(["node", "-e", script], check=True, text=True, capture_output=True)
payload = json.loads(result.stdout)
@@ -49,3 +54,4 @@ console.log(JSON.stringify({{rows: _allSessions, oldStreaming: _sessionStreaming
assert payload["rows"][0]["title"] == "After done"
assert payload["rows"][0]["message_count"] == 3
assert payload["oldStreaming"] is False
assert payload["oldSourceTracked"] is False
@@ -0,0 +1,25 @@
"""Regression coverage for immediate sidebar pin/unpin reordering."""
from pathlib import Path
SESSIONS_JS = (Path(__file__).resolve().parents[1] / "static" / "sessions.js").read_text(encoding="utf-8")
def test_pin_action_updates_local_cache_and_renders_before_refetch():
"""Pin/unpin should move rows immediately, not only after a browser refresh."""
api_idx = SESSIONS_JS.find("await api('/api/session/pin'")
assert api_idx != -1, "pin action API call not found"
snippet = SESSIONS_JS[api_idx:api_idx + 700]
cached_idx = snippet.find("const cached=(_allSessions||[]).find(s=>s&&s.session_id===session.session_id);")
update_idx = snippet.find("if(cached) cached.pinned=newPinned;")
active_idx = snippet.find("if(S.session&&S.session.session_id===session.session_id) S.session.pinned=newPinned;")
cache_render_idx = snippet.find("renderSessionListFromCache();")
refetch_idx = snippet.find("void renderSessionList();")
assert cached_idx != -1, "pin action must find the cached sidebar row"
assert update_idx != -1, "pin action must update cached row.pinned"
assert active_idx != -1, "pin action must update the active session if pinned"
assert cache_render_idx != -1, "pin action must render from cache immediately"
assert refetch_idx != -1, "pin action should still reconcile with the server"
assert cached_idx < update_idx < active_idx < cache_render_idx < refetch_idx
+6 -5
View File
@@ -32,11 +32,12 @@ def test_render_uses_single_pass_partition_helper():
assert "_partitionSidebarSessionRows(allMatched, activeSidForSidebar)" in render_body
assert "_renderSidebarRowsFromRawSessions(sessionsRaw, referenceRaw)" in render_body
assert "? sessions.length" in render_body
assert ": _renderSidebarRowsFromRawSessions(webuiSessionsRaw, webuiReferenceRaw).length;" in render_body
assert ": _renderSidebarRowsFromRawSessions(cliSessionsRaw, cliReferenceRaw).length;" in render_body
assert "const count=filter==='cli'?renderedCliSessionCount:renderedWebuiSessionCount;" in render_body
assert "const count=filter==='cli'?cliSessionCount:webuiSessionCount;" not in render_body
assert "const renderedWebuiSessionCount=_renderSidebarRowsFromRawSessions(webuiSessionsRaw, webuiReferenceRaw).length;" in render_body
assert "const renderedCliSessionCount=_renderSidebarRowsFromRawSessions(cliSessionsRaw, cliReferenceRaw).length;" in render_body
assert "const webuiSessionTabCount=_sessionSourceTabCount('webui', renderedWebuiSessionCount, renderedCliSessionCount);" in render_body
assert "const cliSessionTabCount=_sessionSourceTabCount('cli', renderedWebuiSessionCount, renderedCliSessionCount);" in render_body
assert "const count=filter==='cli'?cliSessionTabCount:webuiSessionTabCount;" in render_body
assert "const count=filter==='cli'?renderedCliSessionCount:renderedWebuiSessionCount;" not in render_body
assert "withMessages.filter(" not in render_body
+6 -6
View File
@@ -94,8 +94,9 @@ def test_unassigned_chip_label_and_handler():
assert "noneChip.textContent='Unassigned';" in js, (
"The Unassigned chip must display the label 'Unassigned'"
)
assert "_activeProject=NO_PROJECT_FILTER" in js, (
"Clicking the Unassigned chip must set _activeProject to the sentinel"
assert "_setActiveProjectFilter(NO_PROJECT_FILTER)" in js, (
"Clicking the Unassigned chip must route through the helper that sets "
"the sentinel and refreshes the payload"
)
# Active-state contract — the chip must reflect when it's the active filter.
assert "_activeProject===NO_PROJECT_FILTER?' active':''" in js, (
@@ -142,8 +143,7 @@ def test_all_chip_clear_clears_unassigned_filter_too():
js = _js()
# Find the "All" chip handler. It must clear _activeProject to null and
# NOT preserve any unassigned-flag state.
assert "allChip.onclick=()=>{_activeProject=null;renderSessionListFromCache();};" in js, (
"The All chip handler must reset _activeProject to null. If a parallel "
"_showNoneProject boolean is reintroduced, this test will catch it because "
"the handler will need additional state to reset."
assert "allChip.onclick=()=>{_setActiveProjectFilter(null);};" in js, (
"The All chip handler must route through the helper that clears "
"_activeProject and refreshes the filtered payload."
)
+20 -7
View File
@@ -85,6 +85,16 @@ def make_session_tracked(created_list, ws=None):
return sid, pathlib.Path(d["session"]["workspace"])
def _make_session_visible(sid):
data, status = post("/api/chat/start", {
"session_id": sid,
"message": "visible row",
"model": "openai/gpt-5.4-mini",
})
assert status == 200, f"chat/start failed with {status}: {data}"
get(f"/api/chat/cancel?stream_id={urllib.parse.quote(data['stream_id'])}")
# ──────────────────────────────────────────────
# Health check (prerequisite for all tests)
@@ -124,7 +134,13 @@ def test_session_create_and_load():
sid = data["session"]["session_id"]
assert len(sid) == 12 # uuid4().hex[:12]
# Give it a title so it's visible in the session list (empty Untitled sessions are filtered)
# Load it directly
loaded = get(f"/api/session?session_id={sid}")
assert loaded["session"]["session_id"] == sid
assert loaded["session"]["messages"] == []
# Give it a real message so the default sidebar route keeps it visible.
_make_session_visible(sid)
post("/api/session/rename", {"session_id": sid, "title": "test-create-verify"})
# Verify it appears in /api/sessions list
@@ -132,11 +148,6 @@ def test_session_create_and_load():
sids = [s["session_id"] for s in sessions["sessions"]]
assert sid in sids, f"New session {sid} not in sessions list"
# Load it directly
loaded = get(f"/api/session?session_id={sid}")
assert loaded["session"]["session_id"] == sid
assert loaded["session"]["messages"] == []
# Cleanup
post("/api/session/delete", {"session_id": sid})
@@ -207,14 +218,16 @@ def test_session_delete_nonexistent():
def test_sessions_list_sorted():
"""Sessions list should be sorted most-recently-updated first."""
# Create two sessions with a title so they're visible (empty Untitled sessions are filtered)
# Create two sessions with a real message so the default sidebar route keeps them visible.
a, _ = post("/api/session/new", {})
time.sleep(0.05)
b, _ = post("/api/session/new", {})
sid_a = a["session"]["session_id"]
sid_b = b["session"]["session_id"]
_make_session_visible(sid_a)
post("/api/session/rename", {"session_id": sid_a, "title": "test-sort-a"})
time.sleep(0.05)
_make_session_visible(sid_b)
post("/api/session/rename", {"session_id": sid_b, "title": "test-sort-b"})
sessions = get("/api/sessions")
+11 -1
View File
@@ -29,6 +29,15 @@ def make_session(created_list):
return sid
def _make_session_visible(sid):
from api.models import Session
session = Session.load(sid)
assert session is not None
session.messages = [{"role": "user", "content": "visible row", "timestamp": 1.0}]
session.save()
# ── Settings API ──────────────────────────────────────────────────────────
def test_settings_get_returns_defaults():
@@ -121,8 +130,9 @@ def test_pinned_in_session_list():
created = []
try:
sid = make_session(created)
# Pin it and give it a title so it shows in the list
# Give it one real message so the default sidebar route keeps it visible.
post("/api/session/rename", {"session_id": sid, "title": "Pinned Test"})
_make_session_visible(sid)
post("/api/session/pin", {"session_id": sid, "pinned": True})
d, _ = get("/api/sessions")
match = [s for s in d['sessions'] if s['session_id'] == sid]
+15 -3
View File
@@ -1,7 +1,7 @@
"""
Sprint 15 Tests: session projects (CRUD, move, backward compat).
"""
import json, urllib.error, urllib.request
import json, urllib.error, urllib.parse, urllib.request
from tests._pytest_port import BASE
@@ -29,6 +29,16 @@ def make_session(created_list):
return sid, d["session"]
def _make_session_visible(sid):
d, status = post("/api/chat/start", {
"session_id": sid,
"message": "visible row",
"model": "openai/gpt-5.4-mini",
})
assert status == 200, f"chat/start failed with {status}: {d}"
get(f"/api/chat/cancel?stream_id={urllib.parse.quote(d['stream_id'])}")
def make_project(created_list, name="Test Project", color=None):
body = {"name": name}
if color:
@@ -196,8 +206,9 @@ def test_session_project_in_list():
try:
pid, _ = make_project(pids, "Listed")
sid, _ = make_session(sids)
# Give it a title so it shows in list (non-empty Untitled sessions are hidden)
# Give it one real message so the default sidebar route keeps it visible.
post("/api/session/rename", {"session_id": sid, "title": "Project Test Session"})
_make_session_visible(sid)
post("/api/session/move", {"session_id": sid, "project_id": pid})
dl, _ = get("/api/sessions")
match = [s for s in dl["sessions"] if s["session_id"] == sid]
@@ -216,7 +227,8 @@ def test_compact_includes_project_id():
sids = []
try:
sid, sess = make_session(sids)
# Give it a title so it appears in the list
# Give it a real message so the default sidebar route keeps it visible.
_make_session_visible(sid)
post("/api/session/rename", {"session_id": sid, "title": "Compat Test"})
dl, _ = get("/api/sessions")
match = [s for s in dl["sessions"] if s["session_id"] == sid]
+20
View File
@@ -30,6 +30,24 @@ def make_session(created_list):
return sid, d["session"]
def _make_session_visible(sid):
from api.models import Session
from tests.conftest import TEST_WORKSPACE
session = Session(
session_id=sid,
title="Compact Usage",
workspace=str(TEST_WORKSPACE),
model="test",
created_at=1.0,
updated_at=1.0,
profile="default",
messages=[{"role": "user", "content": "visible row", "timestamp": 1.0}],
tool_calls=[],
)
session.save(touch_updated_at=False)
# ── Session usage fields ─────────────────────────────────────────────────
def test_new_session_has_usage_fields():
@@ -57,6 +75,8 @@ def test_session_compact_has_usage_fields():
try:
sid, _ = make_session(created)
post("/api/session/rename", {"session_id": sid, "title": "Compact Usage"})
_make_session_visible(sid)
post("/api/session/rename", {"session_id": sid, "title": "Compact Usage"})
d, status = get("/api/sessions")
assert status == 200
match = [s for s in d["sessions"] if s["session_id"] == sid]