Merge pull request #2989 from nesquena/release/stage-batch21

Release DK — stage-batch21 — 5-PR tier-2 batch (v0.51.139)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-05-26 17:10:22 -07:00
commit 8bc3404932
82 files changed
+706 -118

No files matched your search

+19
View File
@@ -3,6 +3,25 @@
## [Unreleased]
## [v0.51.139] — 2026-05-25 — Release DK (stage-batch21 — 5-PR tier-2 batch)
### Added
- Image lightbox now supports prev/next navigation when multiple images are present in the same message. Click `‹` / `›` buttons or use `←` / `→` keyboard arrows to browse; an image counter (`1 / 5`) is shown at the bottom. (#2967)
### Fixed
- Session switching now keeps the initial metadata fetch on `/api/session?messages=0&resolve_model=0` and defers stale model/provider repair until after the new session is assigned, so first paint does not block on cold model catalog hydration.
- Metadata-only session loads now use a cheap state summary instead of full transcript reconciliation, while still detecting real external state.db growth and ignoring restamped replay rows that would otherwise retrigger refresh polling.
- Session transcript reconciliation now precomputes visible-duplicate lookup state instead of recomputing loose-content normalization for every state.db row, reducing long-session tail-load latency without changing the append-only merge contract.
- Vendored KaTeX CSS, JavaScript, and fonts locally so math rendering no longer triggers CSP font reports for `cdn.jsdelivr.net` font files.
- Vendored `js-yaml` locally so YAML tree-view loading no longer triggers CSP script reports for `cdnjs.cloudflare.com`.
- Session delete now prunes the deleted row from `_index.json` in place instead of discarding the whole index, and composer draft saves skip the sidebar index entirely. Both reduce churn on new-session sends after a delete or while typing.
### Changed
- Sidebar chat search now highlights matching title text and shows a subtle content preview for body-only matches.
## [v0.51.138] — 2026-05-25 — Release DJ (stage-batch20 — 7-PR ultra-safe batch)
### Added
+1 -1
View File
@@ -46,7 +46,7 @@ def _security_headers(handler):
"default-src 'self' https://*.cloudflareaccess.com; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
"img-src 'self' data: https: blob:; font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net; "
"img-src 'self' data: https: blob:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net; "
"manifest-src 'self' https://*.cloudflareaccess.com; "
"base-uri 'self'; form-action 'self'"
)
+121 -7
View File
@@ -239,6 +239,44 @@ def _write_session_index(updates=None):
_write_session_index(updates=None)
def prune_session_from_index(session_id: str) -> None:
"""Remove one session row from the persisted sidebar index if present."""
sid = str(session_id or "")
if not sid or not SESSION_INDEX_FILE.exists():
return
_tmp = SESSION_INDEX_FILE.with_suffix(f'.tmp.{os.getpid()}.{threading.current_thread().ident}')
_fallback = False
with _INDEX_WRITE_LOCK:
try:
with LOCK:
existing = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
if not isinstance(existing, list):
raise ValueError("session index must be a list")
pruned = [e for e in existing if e.get('session_id') != sid]
if len(pruned) == len(existing):
return
_payload = json.dumps(pruned, ensure_ascii=False, indent=2)
try:
with open(_tmp, 'w', encoding='utf-8') as f:
f.write(_payload)
f.flush()
os.fsync(f.fileno())
os.replace(_tmp, SESSION_INDEX_FILE)
except Exception:
try:
_tmp.unlink(missing_ok=True)
except Exception:
pass
raise
except Exception:
_fallback = True
if _fallback:
_write_session_index(updates=None)
def _active_stream_ids():
with STREAMS_LOCK:
active_ids = set(STREAMS.keys())
@@ -576,9 +614,10 @@ class Session:
'enabled_toolsets', 'composer_draft',
]
meta = {k: getattr(self, k, None) for k in METADATA_FIELDS}
meta['message_count'] = len(self.messages or [])
meta['messages'] = self.messages
meta['tool_calls'] = self.tool_calls
# Fields not in METADATA_FIELDS (e.g. last_usage, message_count) go at the end
# Fields not in METADATA_FIELDS (e.g. last_usage) go at the end
extra = {k: v for k, v in self.__dict__.items()
if k not in METADATA_FIELDS and k not in ('messages', 'tool_calls')
and not k.startswith('_')}
@@ -3187,6 +3226,53 @@ def get_state_db_session_messages(sid, *, stitch_continuations: bool = False, pr
return msgs
def get_state_db_session_summary(sid, *, profile=None) -> dict:
"""Return a cheap message count/timestamp summary for one state.db session."""
try:
import sqlite3
except ImportError:
return {"message_count": 0, "last_message_at": 0.0}
if isinstance(profile, str) and profile:
db_path = _get_profile_home(profile) / 'state.db'
if not db_path.exists():
db_path = _active_state_db_path()
else:
db_path = _active_state_db_path()
if not sid or not db_path.exists():
return {"message_count": 0, "last_message_at": 0.0}
try:
with closing(sqlite3.connect(str(db_path))) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("PRAGMA table_info(messages)")
available = {str(row['name']) for row in cur.fetchall()}
if 'session_id' not in available:
return {"message_count": 0, "last_message_at": 0.0}
if 'timestamp' in available:
cur.execute(
"SELECT COUNT(*) AS message_count, MAX(timestamp) AS last_message_at "
"FROM messages WHERE session_id = ?",
(str(sid),),
)
row = cur.fetchone()
if not row:
return {"message_count": 0, "last_message_at": 0.0}
return {
"message_count": max(0, int(row["message_count"] or 0)),
"last_message_at": float(row["last_message_at"] or 0) if row["last_message_at"] is not None else 0.0,
}
cur.execute("SELECT COUNT(*) AS message_count FROM messages WHERE session_id = ?", (str(sid),))
row = cur.fetchone()
return {
"message_count": max(0, int(row["message_count"] or 0)) if row else 0,
"last_message_at": 0.0,
}
except Exception:
return {"message_count": 0, "last_message_at": 0.0}
def _normalized_message_timestamp_for_key(value):
if value is None or value == "":
return ""
@@ -3258,19 +3344,38 @@ def _session_message_visible_key(msg: dict):
)
def _matching_visible_duplicate(visible_key: tuple, visible_keys: set[tuple]):
def _build_visible_duplicate_lookup(visible_keys: set[tuple]) -> dict:
by_role = {}
loose_by_key = {}
for key in visible_keys:
try:
role, content = key
except (TypeError, ValueError):
continue
if not content:
continue
by_role.setdefault(role, []).append(key)
loose_by_key[key] = _loose_session_message_content(content)
return {"keys": visible_keys, "by_role": by_role, "loose_by_key": loose_by_key}
def _matching_visible_duplicate(visible_key: tuple, visible_keys: set[tuple], lookup: dict | None = None):
if visible_key in visible_keys:
return visible_key
role, content = visible_key
if not content:
return None
for existing_role, existing_content in visible_keys:
if lookup is None:
lookup = _build_visible_duplicate_lookup(visible_keys)
loose_content = None
for existing_role, existing_content in lookup.get("by_role", {}).get(role, []):
if role != existing_role or not existing_content:
continue
if content in existing_content or existing_content in content:
return (existing_role, existing_content)
loose_content = _loose_session_message_content(content)
loose_existing = _loose_session_message_content(existing_content)
if loose_content is None:
loose_content = _loose_session_message_content(content)
loose_existing = lookup.get("loose_by_key", {}).get((existing_role, existing_content), "")
if loose_content and loose_existing and (
loose_content in loose_existing or loose_existing in loose_content
):
@@ -3376,6 +3481,7 @@ def merge_session_messages_append_only(
sidecar_visible_counts[visible_key] = sidecar_visible_counts.get(visible_key, 0) + 1
sidecar_visible_sequence.append(visible_key)
merged_messages.append(msg)
sidecar_visible_lookup = _build_visible_duplicate_lookup(sidecar_visible_keys)
state_replay_idx = 0
skipped_state_visible_counts = {}
for msg in state_messages:
@@ -3391,7 +3497,11 @@ def merge_session_messages_append_only(
replays_sidecar_prefix = True
state_replay_idx += 1
if replays_sidecar_prefix:
matched_visible_key = _matching_visible_duplicate(visible_key, sidecar_visible_keys)
matched_visible_key = _matching_visible_duplicate(
visible_key,
sidecar_visible_keys,
sidecar_visible_lookup,
)
if matched_visible_key is not None:
skipped_state_visible_counts[matched_visible_key] = (
skipped_state_visible_counts.get(matched_visible_key, 0) + 1
@@ -3411,7 +3521,11 @@ def merge_session_messages_append_only(
continue
if key in seen_message_keys:
continue
matched_visible_key = _matching_visible_duplicate(visible_key, sidecar_visible_keys)
matched_visible_key = _matching_visible_duplicate(
visible_key,
sidecar_visible_keys,
sidecar_visible_lookup,
)
if matched_visible_key is not None:
skipped_count = skipped_state_visible_counts.get(matched_visible_key, 0)
sidecar_count = sidecar_visible_counts.get(matched_visible_key, 0)
+99 -25
View File
@@ -2206,25 +2206,55 @@ def _message_summary(messages) -> dict:
def _metadata_only_message_summary(sid: str, profile: str | None = None) -> dict:
"""Return the reconciled message summary used by metadata-only session loads.
"""Return the cheap message summary used by metadata-only session loads.
Threads ``profile=`` through to ``get_state_db_session_messages`` so
Threads ``profile=`` through to ``get_state_db_session_summary`` so
background-thread reads land on the correct profile's state.db (per the
cookie-bound profile selector — fixes the same TLS-vs-thread race the
#2762 fix addressed for write paths).
This intentionally does not full-read or merge transcripts. If state.db has
grown beyond the sidecar count, report that growth so active-session polling
can refresh. If state.db only contains restamped replay rows at or below the
sidecar count, keep the sidecar metadata so polling does not loop forever on
a false "newer transcript" signal.
"""
sidecar_session = Session.load(sid)
sidecar_messages = []
sidecar_session = Session.load_metadata_only(sid)
sidecar_count = 0
sidecar_last_message_at = 0.0
if sidecar_session:
sidecar_messages = getattr(sidecar_session, "messages", []) or []
state_db_messages = get_state_db_session_messages(sid, profile=profile)
return _message_summary(
merge_session_messages_append_only(
sidecar_messages,
state_db_messages,
truncation_watermark=getattr(sidecar_session, "truncation_watermark", None),
)
)
sidecar_count = _numeric_count(getattr(sidecar_session, "_metadata_message_count", None))
if sidecar_count <= 0:
sidecar_count = _numeric_count(sidecar_session.compact().get("message_count"))
try:
sidecar_last_message_at = float(getattr(sidecar_session, "updated_at", 0) or 0)
except (TypeError, ValueError):
sidecar_last_message_at = 0.0
if getattr(sidecar_session, "truncation_watermark", None) is not None:
# Intentional: once the user has truncated this sidecar, metadata
# polling must keep the sidecar as authoritative. A full message
# load can still apply the watermark-aware merge, but the cheap
# metadata path should not treat later state.db rows as external
# growth and resurrect turns the user deliberately cut away.
return {
"message_count": sidecar_count,
"last_message_at": sidecar_last_message_at,
}
state_summary = get_state_db_session_summary(sid, profile=profile)
state_count = _numeric_count(state_summary.get("message_count"))
try:
state_last_message_at = float(state_summary.get("last_message_at") or 0)
except (TypeError, ValueError):
state_last_message_at = 0.0
if state_count > sidecar_count and state_last_message_at > sidecar_last_message_at:
return {
"message_count": state_count,
"last_message_at": state_last_message_at,
}
return {
"message_count": sidecar_count,
"last_message_at": sidecar_last_message_at,
}
def _session_requires_cli_metadata_lookup(session) -> bool:
@@ -2457,8 +2487,10 @@ from api.models import (
get_cli_sessions,
get_cli_session_messages,
get_state_db_session_messages,
get_state_db_session_summary,
merge_session_messages_append_only,
_session_message_merge_key,
prune_session_from_index,
ensure_cron_project,
is_cron_session,
)
@@ -5306,7 +5338,7 @@ def handle_post(handler, parsed) -> bool:
# here makes the active-session external-refresh poll force-reload the
# current chat every few seconds while the user is typing, and that
# delayed reload can restore an older draft over newer local input.
s.save(touch_updated_at=False)
s.save(touch_updated_at=False, skip_index=True)
return j(handler, {"ok": True, "draft": s.composer_draft})
if parsed.path == "/api/session/update":
@@ -5392,10 +5424,6 @@ def handle_post(handler, parsed) -> bool:
# Delete from WebUI session store
with LOCK:
SESSIONS.pop(sid, None)
try:
SESSION_INDEX_FILE.unlink(missing_ok=True)
except Exception:
logger.debug("Failed to unlink session index")
# Evict cached agent so turn count doesn't leak into a recycled session
from api.config import _evict_session_agent
_evict_session_agent(sid)
@@ -5409,6 +5437,10 @@ def handle_post(handler, parsed) -> bool:
p.with_suffix('.json.bak').unlink(missing_ok=True)
except Exception:
logger.debug("Failed to unlink session file %s", p)
try:
prune_session_from_index(sid)
except Exception:
logger.debug("Failed to prune deleted session from index: %s", sid, exc_info=True)
try:
from api.upload import _session_attachment_dir
@@ -6787,6 +6819,51 @@ def _handle_session_export(handler, parsed):
return True
def _session_search_message_text(message):
content = message.get("content") if isinstance(message, dict) else ""
if isinstance(content, list):
return " ".join(
str(part.get("text", ""))
for part in content
if isinstance(part, dict) and part.get("type") == "text"
)
return str(content or "")
def _session_search_preview(text, query, max_len=124):
normalized = re.sub(r"\s+", " ", str(text or "")).strip()
q = re.sub(r"\s+", " ", str(query or "")).strip()
if not normalized or not q:
return ""
idx = normalized.lower().find(q.lower())
if idx < 0:
return ""
max_len = max(32, int(max_len or 124))
if len(normalized) <= max_len:
return normalized
context = max(12, (max_len - len(q)) // 2)
start = max(0, idx - context)
end = min(len(normalized), idx + len(q) + context)
if start > 0:
while start < idx and normalized[start] != " ":
start += 1
if start >= idx:
start = max(0, idx - context)
if end < len(normalized):
while end > idx + len(q) and normalized[end - 1] != " ":
end -= 1
if end <= idx + len(q):
end = min(len(normalized), idx + len(q) + context)
excerpt = normalized[start:end].strip()
if start > 0:
excerpt = "..." + excerpt
if end < len(normalized):
excerpt = excerpt + "..."
return excerpt
def _handle_sessions_search(handler, parsed):
qs = parse_qs(parsed.query)
q = qs.get("q", [""])[0].lower().strip()
@@ -6814,15 +6891,12 @@ def _handle_sessions_search(handler, parsed):
sess = get_session(s["session_id"])
msgs = sess.messages[:depth] if depth else sess.messages
for m in msgs:
c = m.get("content") or ""
if isinstance(c, list):
c = " ".join(
p.get("text", "")
for p in c
if isinstance(p, dict) and p.get("type") == "text"
)
c = _session_search_message_text(m)
if q in str(c).lower():
item = dict(s, match_type="content")
preview = _session_search_preview(c, q)
if preview:
item["match_preview"] = _redact_text(preview)
if isinstance(item.get("title"), str):
item["title"] = _redact_text(item["title"])
results.append(item)
+2 -2
View File
@@ -70,8 +70,8 @@
})()</script>
<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" integrity="sha384-LJcOxlx9IMbNXDqJ2axpfEQKkAYbFjJfhXexLfiRJhjDU81mzgkiQq8rkV0j6dVh" crossorigin="anonymous">
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" integrity="sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP" crossorigin="anonymous">
<!-- KaTeX math rendering CSS (loaded eagerly from vendored assets to prevent font CSP noise) -->
<link rel="stylesheet" href="static/vendor/katex/0.16.22/katex.min.css">
<!-- streaming-markdown: incremental DOM-building markdown parser for live streams -->
<!-- Self-hosted from npm:streaming-markdown@0.2.15 — no CDN dependency. -->
<!-- sha384 of smd.min.js @0.2.15: sha384-T6r95ocN9t3W8tUK2Fa6FPaO7bJryyjyW0WCalrUnpgtm2qXr5xcN4vwPYEJ6vHa -->
+88 -11
View File
@@ -579,15 +579,13 @@ async function loadSession(sid){
const _msgInner = $('msgInner');
if (_msgInner && currentSid !== sid) _msgInner.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Loading conversation...</div>';
}
// Phase 1: Load metadata only (~1KB) for fast session switching.
// Resolve model immediately: old sessions can persist stale provider-shaped
// IDs (e.g. openai/gpt-5.4-mini) and assigning those to S.session creates a
// short race where the composer can display/send the wrong model before the
// deferred resolver catches up.
// Phase 1: Load metadata only (~1KB) for fast session switching. Keep model
// resolution out of the first-paint path; old provider-shaped model IDs are
// repaired by the deferred resolver after S.session is assigned.
// Guard against network/server failures to prevent a permanently stuck loading state.
let data;
try {
data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=1`);
data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`);
} catch(e) {
const _msgInner = $('msgInner');
if(_msgInner){
@@ -628,6 +626,7 @@ async function loadSession(sid){
try { window._resetScrollDirectionTracker(); } catch (_) {}
}
if(typeof _applyPendingSessionModelForSession==='function') _applyPendingSessionModelForSession(sid);
_resolveSessionModelForDisplaySoon(sid);
// Sync workspace display immediately so the chip label reflects the new session's workspace
// before any async message-loading begins (mirrors how model is handled).
if(typeof syncTopbar==='function') syncTopbar();
@@ -828,7 +827,6 @@ async function loadSession(sid){
_restoreComposerDraft(_draft, sid, {preserveActiveInput:currentSid===sid&&forceReload});
}
_resolveSessionModelForDisplaySoon(sid);
// Clear the in-flight session marker now that this load has completed (#1060).
if (_loadingSessionId === sid) _loadingSessionId = null;
@@ -2487,19 +2485,85 @@ function stopGatewaySSE(){
let _searchDebounceTimer = null;
let _contentSearchResults = []; // results from /api/sessions/search content scan
let _lastSessionSearchQuery = '';
let _hideSearchPreviewsAfterSelect = false;
let _serverTimeDelta = 0; // ms offset: client clock - server clock (for clock-skew compensation)
let _serverTz = ''; // server timezone offset string (e.g. "+0800", "+0000", "-0500")
function _sessionSearchRanges(text, query){
const source=String(text||'');
const q=String(query||'').trim();
if(!source||!q) return [];
const lower=source.toLowerCase();
const full=q.toLowerCase();
const ranges=[];
const collect=(needle)=>{
if(!needle) return;
let from=0;
while(from<lower.length){
const idx=lower.indexOf(needle,from);
if(idx<0) break;
const end=idx+needle.length;
if(!ranges.some(r=>idx<r.end&&end>r.start)) ranges.push({start:idx,end});
from=Math.max(end,idx+1);
}
};
collect(full);
if(!ranges.length&&/\s/.test(full)){
const seen=new Set();
full.split(/\s+/).filter(Boolean).sort((a,b)=>b.length-a.length).forEach(token=>{
if(seen.has(token)) return;
seen.add(token);
collect(token);
});
}
return ranges.sort((a,b)=>a.start-b.start);
}
function _appendHighlightedText(parent, text, query, highlightClass){
const source=String(text||'');
const ranges=_sessionSearchRanges(source,query);
if(!ranges.length){
parent.appendChild(document.createTextNode(source));
return ranges;
}
let pos=0;
for(const r of ranges){
if(r.start>pos) parent.appendChild(document.createTextNode(source.slice(pos,r.start)));
const mark=document.createElement('span');
mark.className=highlightClass||'session-search-hit';
mark.textContent=source.slice(r.start,r.end);
parent.appendChild(mark);
pos=r.end;
}
if(pos<source.length) parent.appendChild(document.createTextNode(source.slice(pos)));
return ranges;
}
function _sessionSearchContentPreview(session, query){
if(!session||!query||_hideSearchPreviewsAfterSelect) return '';
if(session.match_type!=='content') return '';
const preview=String(session.match_preview||'').replace(/\s+/g,' ').trim();
return preview||'';
}
function filterSessions(){
// Immediate client-side title filter (no flicker)
renderSessionListFromCache();
// Debounced content search via API for message text
const q = ($('sessionSearch').value || '').trim();
if(q!==_lastSessionSearchQuery){
_lastSessionSearchQuery=q;
_hideSearchPreviewsAfterSelect=false;
}
renderSessionListFromCache();
clearTimeout(_searchDebounceTimer);
if (!q) { _contentSearchResults = []; return; }
_searchDebounceTimer = setTimeout(async () => {
const requestedQ = q;
try {
const data = await api(`/api/sessions/search?q=${encodeURIComponent(q)}&content=1&depth=5`);
const data = await api(`/api/sessions/search?q=${encodeURIComponent(requestedQ)}&content=1&depth=5`);
const currentQ = ($('sessionSearch').value || '').trim();
if(currentQ!==requestedQ) return;
const titleIds = new Set(_allSessions.filter(s => _sessionDisplayTitle(s).toLowerCase().includes(q.toLowerCase())).map(s=>s.session_id));
_contentSearchResults = (data.sessions||[]).filter(s => s.match_type === 'content' && !titleIds.has(s.session_id));
renderSessionListFromCache();
@@ -3096,7 +3160,8 @@ function renderSessionListFromCache(){
// streaming. This runs on every list refresh to prevent memory leaks from
// interrupted streams. (#2066)
_purgeStaleInflightEntries();
const q=($('sessionSearch').value||'').toLowerCase();
const searchQueryRaw=($('sessionSearch').value||'').trim();
const q=searchQueryRaw.toLowerCase();
const activeSidForSidebar=_activeSessionIdForSidebar();
const titleMatches=q?_allSessions.filter(s=>_sessionDisplayTitle(s).toLowerCase().includes(q)):_allSessions;
// Merge content matches (deduped): content matches appended after title matches
@@ -3452,7 +3517,10 @@ function renderSessionListFromCache(){
}
const title=document.createElement('span');
title.className='session-title';
title.textContent=cleanTitle||'Untitled';
const displayTitle=cleanTitle||'Untitled';
const titleMatched=Boolean(searchQueryRaw&&displayTitle.toLowerCase().includes(searchQueryRaw.toLowerCase()));
if(titleMatched) _appendHighlightedText(title,displayTitle,searchQueryRaw,'session-search-hit');
else title.textContent=displayTitle;
title.title=readOnly?'Read-only imported session':'Double-click to rename';
const tsMs=_sessionTimestampMs(s);
const ts=document.createElement('span');
@@ -3550,6 +3618,14 @@ function renderSessionListFromCache(){
meta.textContent=metaBits.join(' · ');
sessionText.appendChild(meta);
}
const contentPreview=titleMatched?'':_sessionSearchContentPreview(s,searchQueryRaw);
if(contentPreview){
const preview=document.createElement('div');
preview.className='session-search-preview';
preview.title=contentPreview;
_appendHighlightedText(preview,contentPreview,searchQueryRaw,'session-search-hit session-search-hit-preview');
sessionText.appendChild(preview);
}
if(lineageSegmentsExpanded){
const lineageList=document.createElement('div');
lineageList.className='session-lineage-segments';
@@ -3811,6 +3887,7 @@ function renderSessionListFromCache(){
await api('/api/session/import_cli',{method:'POST',body:JSON.stringify({session_id:s.session_id})});
}catch(e){ /* import failed -- fall through to read-only view */ }
}
if(($('sessionSearch').value||'').trim()) _hideSearchPreviewsAfterSelect=true;
await loadSession(s.session_id);renderSessionListFromCache();
if(typeof closeMobileSidebar==='function')closeMobileSidebar();
}, delay);
+13 -1
View File
@@ -331,9 +331,11 @@
:root.dark[data-skin="nous"] .session-child-count:hover{background:rgba(99,179,237,.26);color:#7EB6E0;}
:root.dark[data-skin="nous"] .session-tree-badge{background:rgba(99,179,237,.2);color:#4682B4;}
:root.dark[data-skin="nous"] .session-tag{background:rgba(99,179,237,.2);color:#4682B4;}
/* Image lightbox close */
/* Image lightbox */
:root[data-skin="nous"] .img-lightbox-close:hover{background:rgba(70,130,180,.4);}
:root.dark[data-skin="nous"] .img-lightbox-close:hover{background:rgba(70,130,180,.4);}
:root[data-skin="nous"] .img-lightbox-nav:hover{background:rgba(70,130,180,.5);}
:root.dark[data-skin="nous"] .img-lightbox-nav:hover{background:rgba(70,130,180,.5);}
/* Diff blocks */
:root[data-skin="nous"] .diff-block .diff-plus{color:#4682B4;}
:root.dark[data-skin="nous"] .diff-block .diff-plus{color:#7EB6E0;}
@@ -730,8 +732,13 @@
.session-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;overflow:hidden;}
.session-title-row{display:flex;align-items:center;gap:6px;min-width:0;}
.session-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);user-select:none;}
.session-search-hit{border-radius:4px;background:var(--accent-bg);color:var(--accent-text);box-decoration-break:clone;-webkit-box-decoration-break:clone;padding:0 .08em;}
.session-search-preview{font-size:11px;line-height:1.35;color:var(--muted);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow-wrap:anywhere;opacity:.9;}
.session-search-hit-preview{background:var(--accent-bg);color:var(--accent-text);}
.session-item.active .session-title{color:var(--accent-text);}
.session-meta{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.session-item.active .session-search-preview{color:var(--accent-text);opacity:.72;}
.session-item.active .session-search-hit{background:var(--accent-bg-strong);color:var(--accent-text);}
.session-item.active .session-meta{color:var(--accent-text);opacity:.8;}
.session-state-indicator{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:10px;height:10px;color:var(--accent);visibility:hidden;}
.session-attention-indicator{position:absolute;right:6px;top:50%;transform:translateY(-50%);width:26px;height:26px;z-index:1;pointer-events:none;transition:opacity .15s ease,visibility .15s ease;}
@@ -1323,6 +1330,11 @@
.img-lightbox img{max-width:90vw;max-height:90vh;object-fit:contain;border-radius:8px;box-shadow:0 8px 48px rgba(0,0,0,.6);cursor:default;}
.img-lightbox-close{position:absolute;top:16px;right:20px;width:36px;height:36px;border:none;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s;}
.img-lightbox-close:hover{background:rgba(255,255,255,.22);}
.img-lightbox-nav{position:absolute;top:50%;transform:translateY(-50%);width:44px;height:44px;border:none;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;font-size:28px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s,opacity .15s;z-index:1;opacity:.6;}
.img-lightbox-nav:hover{background:rgba(255,255,255,.28);opacity:1;}
.img-lightbox-nav-prev{left:16px;}
.img-lightbox-nav-next{right:16px;}
.img-lightbox-counter{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.5);color:#fff;font-size:14px;padding:4px 14px;border-radius:12px;pointer-events:none;}
.msg-media-link{display:inline-flex;align-items:center;gap:5px;background:var(--accent-bg);border:1px solid var(--accent-bg-strong);border-radius:6px;padding:4px 10px;font-size:13px;color:var(--accent-text);text-decoration:none;}
.msg-media-link:hover{background:var(--accent-bg-strong);}
+77 -10
View File
@@ -497,7 +497,31 @@ if(document.readyState==='complete'){
}
/* ── Image lightbox — click any .msg-media-img to enlarge ─────────────────── */
function _openImgLightbox(src, alt) {
function _openImgLightbox(imgEl) {
if(!imgEl || !imgEl.src) return;
const src=imgEl.src, alt=imgEl.alt||'';
// Find sibling images in the same message for prev/next navigation.
// Walk up from the clicked image to find the message container, then
// collect all .msg-media-img within it.
// Composer attach-tray chips bypass sibling detection — each chip click
// opens a single-image lightbox (no navigation between staged uploads).
let allImages = [];
let startIndex = 0;
if(!imgEl.closest('.attach-tray')){
let container = imgEl.closest('.msg-row, .assistant-turn-blocks, .assistant-turn, .user-turn');
if(!container) container = imgEl.parentElement;
if(container){
const siblings = container.querySelectorAll('.msg-media-img');
if(siblings.length>1){
allImages = Array.from(siblings);
startIndex = allImages.indexOf(imgEl);
if(startIndex===-1) startIndex=0;
}
}
}
_openImgLightboxWithNav(src, alt, allImages, startIndex);
}
function _openImgLightboxWithNav(src, alt, images, index) {
const lb = document.createElement('div');
lb.className = 'img-lightbox';
lb.setAttribute('role', 'dialog');
@@ -513,15 +537,58 @@ function _openImgLightbox(src, alt) {
cls.onclick = () => _closeImgLightbox(lb);
lb.appendChild(img);
lb.appendChild(cls);
// Prev/Next navigation — store index and images on lb so a single set of
// handlers reads live values without closure churn on every nav.
lb._navIndex = index;
lb._navImages = (images && images.length>1) ? images : null;
if(lb._navImages){
const prevBtn = document.createElement('button');
prevBtn.className = 'img-lightbox-nav img-lightbox-nav-prev';
prevBtn.setAttribute('aria-label', 'Previous image');
prevBtn.innerHTML = '‹';
prevBtn.onclick = e => { e.stopPropagation(); _navigateLightbox(lb, -1); };
lb.appendChild(prevBtn);
const nextBtn = document.createElement('button');
nextBtn.className = 'img-lightbox-nav img-lightbox-nav-next';
nextBtn.setAttribute('aria-label', 'Next image');
nextBtn.innerHTML = '›';
nextBtn.onclick = e => { e.stopPropagation(); _navigateLightbox(lb, 1); };
lb.appendChild(nextBtn);
lb._counterEl = document.createElement('div');
lb._counterEl.className = 'img-lightbox-counter';
lb.appendChild(lb._counterEl);
lb._counterEl.textContent = (index+1) + ' / ' + images.length;
}
lb.onclick = () => _closeImgLightbox(lb);
document.body.appendChild(lb);
// Close on Escape
lb._escHandler = e => { if(e.key==='Escape') _closeImgLightbox(lb); };
document.addEventListener('keydown', lb._escHandler);
// Single keyboard handler — reads lb._navX live, no remove/add churn.
lb._keyHandler = e => {
if(e.key==='Escape'){ _closeImgLightbox(lb); return; }
if(lb._navImages){
if(e.key==='ArrowLeft'){ e.preventDefault(); _navigateLightbox(lb, -1); }
if(e.key==='ArrowRight'){ e.preventDefault(); _navigateLightbox(lb, 1); }
}
};
document.addEventListener('keydown', lb._keyHandler);
}
function _navigateLightbox(lb, direction) {
const images = lb._navImages;
if(!images) return;
const newIndex = lb._navIndex + direction;
if(newIndex<0 || newIndex>=images.length) return;
lb._navIndex = newIndex;
const nextImg = images[newIndex];
const lbImg = lb.querySelector('img');
if(!lbImg) return;
lbImg.src = nextImg.src;
lbImg.alt = nextImg.alt || '';
lb.setAttribute('aria-label', nextImg.alt || 'Image');
// Update counter via stored reference — no DOM query.
if(lb._counterEl) lb._counterEl.textContent = (newIndex+1) + ' / ' + images.length;
}
function _closeImgLightbox(lb) {
if(!lb || !lb.parentNode) return;
document.removeEventListener('keydown', lb._escHandler);
document.removeEventListener('keydown', lb._keyHandler);
lb.style.animation = 'lb-in .12s ease reverse';
setTimeout(() => lb.parentNode && lb.parentNode.removeChild(lb), 120);
}
@@ -530,14 +597,14 @@ document.addEventListener('click', e => {
if(!e.target || !e.target.closest) return;
// Message-attached images (already wired since v0.50.x).
let img = e.target.closest('.msg-media-img');
if(img){ _openImgLightbox(img.src, img.alt); return; }
if(img){ _openImgLightbox(img); return; }
// Composer attach-tray image thumbnails — click any pasted/dropped image
// chip to lightbox-zoom it before sending. Excludes audio/video chips,
// which keep their inline media controls. SVG thumbnails (.attach-thumb--svg)
// are still images visually, so they qualify.
img = e.target.closest('.attach-thumb');
if(img && img.tagName === 'IMG'){
_openImgLightbox(img.src, img.alt || img.title || 'Attached image');
_openImgLightbox(img);
return;
}
});
@@ -7031,8 +7098,8 @@ function _loadJsyamlThen(cb){
if(_jsyamlLoading){ setTimeout(()=>_loadJsyamlThen(cb),100); return; }
_jsyamlLoading=true;
const s=document.createElement('script');
s.src='https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js';
s.integrity='sha384-8pLvVQkv7pCQqFk7AChLpdEe7gXz9h8GAb7cS0zVeJuKhxR5PU5aEET5pRpHZvxUorzdM';
s.src='static/vendor/js-yaml/4.1.0/js-yaml.min.js';
s.integrity='sha384-+pxiN6T7yvpryuJmE1gM9PX7yQit15auDb+ZwwvJOd/4be2Cie5/IuVXgQb/S9du';
s.crossOrigin='anonymous';
s.onload=()=>{ _jsyamlLoading=false; cb(); };
s.onerror=()=>{ _jsyamlLoading=false; }; // CDN blocked, fall back to raw
@@ -7552,7 +7619,7 @@ function renderKatexBlocks(container){
if(!_katexLoading){
_katexLoading=true;
const script=document.createElement('script');
script.src='https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js';
script.src='static/vendor/katex/0.16.22/katex.min.js';
script.integrity='sha384-cMkvdD8LoxVzGF/RPUKAcvmm49FQ0oxwDF3BGKtDXcEc+T1b2N+teh/OJfpU0jr6';
script.crossOrigin='anonymous';
script.onload=()=>{
+2
View File
@@ -0,0 +1,2 @@
/*! js-yaml 4.1.0 https://github.com/nodeca/js-yaml @license MIT */
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).jsyaml={})}(this,(function(e){"use strict";function t(e){return null==e}var n={isNothing:t,isObject:function(e){return"object"==typeof e&&null!==e},toArray:function(e){return Array.isArray(e)?e:t(e)?[]:[e]},repeat:function(e,t){var n,i="";for(n=0;n<t;n+=1)i+=e;return i},isNegativeZero:function(e){return 0===e&&Number.NEGATIVE_INFINITY===1/e},extend:function(e,t){var n,i,r,o;if(t)for(n=0,i=(o=Object.keys(t)).length;n<i;n+=1)e[r=o[n]]=t[r];return e}};function i(e,t){var n="",i=e.reason||"(unknown reason)";return e.mark?(e.mark.name&&(n+='in "'+e.mark.name+'" '),n+="("+(e.mark.line+1)+":"+(e.mark.column+1)+")",!t&&e.mark.snippet&&(n+="\n\n"+e.mark.snippet),i+" "+n):i}function r(e,t){Error.call(this),this.name="YAMLException",this.reason=e,this.mark=t,this.message=i(this,!1),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=(new Error).stack||""}r.prototype=Object.create(Error.prototype),r.prototype.constructor=r,r.prototype.toString=function(e){return this.name+": "+i(this,e)};var o=r;function a(e,t,n,i,r){var o="",a="",l=Math.floor(r/2)-1;return i-t>l&&(t=i-l+(o=" ... ").length),n-i>l&&(n=i+l-(a=" ...").length),{str:o+e.slice(t,n).replace(/\t/g,"→")+a,pos:i-t+o.length}}function l(e,t){return n.repeat(" ",t-e.length)+e}var c=function(e,t){if(t=Object.create(t||null),!e.buffer)return null;t.maxLength||(t.maxLength=79),"number"!=typeof t.indent&&(t.indent=1),"number"!=typeof t.linesBefore&&(t.linesBefore=3),"number"!=typeof t.linesAfter&&(t.linesAfter=2);for(var i,r=/\r?\n|\r|\0/g,o=[0],c=[],s=-1;i=r.exec(e.buffer);)c.push(i.index),o.push(i.index+i[0].length),e.position<=i.index&&s<0&&(s=o.length-2);s<0&&(s=o.length-1);var u,p,f="",d=Math.min(e.line+t.linesAfter,c.length).toString().length,h=t.maxLength-(t.indent+d+3);for(u=1;u<=t.linesBefore&&!(s-u<0);u++)p=a(e.buffer,o[s-u],c[s-u],e.position-(o[s]-o[s-u]),h),f=n.repeat(" ",t.indent)+l((e.line-u+1).toString(),d)+" | "+p.str+"\n"+f;for(p=a(e.buffer,o[s],c[s],e.position,h),f+=n.repeat(" ",t.indent)+l((e.line+1).toString(),d)+" | "+p.str+"\n",f+=n.repeat("-",t.indent+d+3+p.pos)+"^\n",u=1;u<=t.linesAfter&&!(s+u>=c.length);u++)p=a(e.buffer,o[s+u],c[s+u],e.position-(o[s]-o[s+u]),h),f+=n.repeat(" ",t.indent)+l((e.line+u+1).toString(),d)+" | "+p.str+"\n";return f.replace(/\n$/,"")},s=["kind","multi","resolve","construct","instanceOf","predicate","represent","representName","defaultStyle","styleAliases"],u=["scalar","sequence","mapping"];var p=function(e,t){if(t=t||{},Object.keys(t).forEach((function(t){if(-1===s.indexOf(t))throw new o('Unknown option "'+t+'" is met in definition of "'+e+'" YAML type.')})),this.options=t,this.tag=e,this.kind=t.kind||null,this.resolve=t.resolve||function(){return!0},this.construct=t.construct||function(e){return e},this.instanceOf=t.instanceOf||null,this.predicate=t.predicate||null,this.represent=t.represent||null,this.representName=t.representName||null,this.defaultStyle=t.defaultStyle||null,this.multi=t.multi||!1,this.styleAliases=function(e){var t={};return null!==e&&Object.keys(e).forEach((function(n){e[n].forEach((function(e){t[String(e)]=n}))})),t}(t.styleAliases||null),-1===u.indexOf(this.kind))throw new o('Unknown kind "'+this.kind+'" is specified for "'+e+'" YAML type.')};function f(e,t){var n=[];return e[t].forEach((function(e){var t=n.length;n.forEach((function(n,i){n.tag===e.tag&&n.kind===e.kind&&n.multi===e.multi&&(t=i)})),n[t]=e})),n}function d(e){return this.extend(e)}d.prototype.extend=function(e){var t=[],n=[];if(e instanceof p)n.push(e);else if(Array.isArray(e))n=n.concat(e);else{if(!e||!Array.isArray(e.implicit)&&!Array.isArray(e.explicit))throw new o("Schema.extend argument should be a Type, [ Type ], or a schema definition ({ implicit: [...], explicit: [...] })");e.implicit&&(t=t.concat(e.implicit)),e.explicit&&(n=n.concat(e.explicit))}t.forEach((function(e){if(!(e instanceof p))throw new o("Specified list of YAML types (or a single Type object) contains a non-Type object.");if(e.loadKind&&"scalar"!==e.loadKind)throw new o("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported.");if(e.multi)throw new o("There is a multi type in the implicit list of a schema. Multi tags can only be listed as explicit.")})),n.forEach((function(e){if(!(e instanceof p))throw new o("Specified list of YAML types (or a single Type object) contains a non-Type object.")}));var i=Object.create(d.prototype);return i.implicit=(this.implicit||[]).concat(t),i.explicit=(this.explicit||[]).concat(n),i.compiledImplicit=f(i,"implicit"),i.compiledExplicit=f(i,"explicit"),i.compiledTypeMap=function(){var e,t,n={scalar:{},sequence:{},mapping:{},fallback:{},multi:{scalar:[],sequence:[],mapping:[],fallback:[]}};function i(e){e.multi?(n.multi[e.kind].push(e),n.multi.fallback.Line truncated
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
@font-face{font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(fonts/KaTeX_AMS-Regular.woff2) format("woff2"),url(fonts/KaTeX_AMS-Regular.woff) format("woff"),url(fonts/KaTeX_AMS-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Bold.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Regular.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Bold.woff) format("woff"),url(fonts/KaTeX_Fraktur-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Regular.woff) format("woff"),url(fonts/KaTeX_Fraktur-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Main-Bold.woff2) format("woff2"),url(fonts/KaTeX_Main-Bold.woff) format("woff"),url(fonts/KaTeX_Main-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Main-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Main-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Main-Italic.woff2) format("woff2"),url(fonts/KaTeX_Main-Italic.woff) format("woff"),url(fonts/KaTeX_Main-Italic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Main-Regular.woff2) format("woff2"),url(fonts/KaTeX_Main-Regular.woff) format("woff"),url(fonts/KaTeX_Main-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Math-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Math-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Math-Italic.woff2) format("woff2"),url(fonts/KaTeX_Math-Italic.woff) format("woff"),url(fonts/KaTeX_Math-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:700;src:url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Bold.woff) format("woff"),url(fonts/KaTeX_SansSerif-Bold.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:italic;font-weight:400;src:url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Italic.woff) format("woff"),url(fonts/KaTeX_SansSerif-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:400;src:url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Regular.woff) format("woff"),url(fonts/KaTeX_SansSerif-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Script-Regular.woff2) format("woff2"),url(fonts/KaTeX_Script-Regular.woff) format("woff"),url(fonts/KaTeX_Script-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size1-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size1-Regular.woff) format("woff"),url(fonts/KaTeX_Size1-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size2-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size2-Regular.woff) format("woff"),url(fonts/KaTeX_Size2-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size3-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size3-Regular.woff) format("woff"),url(fonts/KaTeX_Size3-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size4-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size4-Regular.woff) format("woff"),url(fonts/KaTeX_Size4-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2"),url(fonts/KaTeX_Typewriter-Regular.woff) format("woff"),url(fonts/KaTeX_Typewriter-Regular.ttf) format("truetype")}.katex{font:normal 1.21em KaTeX_Main,Times New Roman,serif;line-height:1.2;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important;border-color:currentColor}.katex .katex-version:after{content:"0.16.22"}.katex .katex-mathml{clip:rect(1px,1px,1px,1px);border:0;height:1pLine truncated
+1
View File
@@ -0,0 +1 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.katex=t():e.katex=t()}("undefined"!=typeof self?self:this,(function(){return function(){"use strict";var e={d:function(t,r){for(var n in r)e.o(r,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:r[n]})},o:function(e,t){return Object.prototype.hasOwnProperty.call(e,t)}},t={};e.d(t,{default:function(){return Wn}});class r{constructor(e,t){this.name=void 0,this.position=void 0,this.length=void 0,this.rawMessage=void 0;let n,o,s="KaTeX parse error: "+e;const i=t&&t.loc;if(i&&i.start<=i.end){const e=i.lexer.input;n=i.start,o=i.end,n===e.length?s+=" at end of input: ":s+=" at position "+(n+1)+": ";const t=e.slice(n,o).replace(/[^]/g,"$&\u0332");let r,a;r=n>15?"\u2026"+e.slice(n-15,n):e.slice(0,n),a=o+15<e.length?e.slice(o,o+15)+"\u2026":e.slice(o),s+=r+t+a}const a=new Error(s);return a.name="ParseError",a.__proto__=r.prototype,a.position=n,null!=n&&null!=o&&(a.length=o-n),a.rawMessage=e,a}}r.prototype.__proto__=Error.prototype;var n=r;const o=/([A-Z])/g,s={"&":"&amp;",">":"&gt;","<":"&lt;",'"':"&quot;","'":"&#x27;"},i=/[&><"']/g;const a=function(e){return"ordgroup"===e.type||"color"===e.type?1===e.body.length?a(e.body[0]):e:"font"===e.type?a(e.body):e};var l={contains:function(e,t){return-1!==e.indexOf(t)},deflt:function(e,t){return void 0===e?t:e},escape:function(e){return String(e).replace(i,(e=>s[e]))},hyphenate:function(e){return e.replace(o,"-$1").toLowerCase()},getBaseElem:a,isCharacterBox:function(e){const t=a(e);return"mathord"===t.type||"textord"===t.type||"atom"===t.type},protocolFromUrl:function(e){const t=/^[\x00-\x20]*([^\\/#?]*?)(:|&#0*58|&#x0*3a|&colon)/i.exec(e);return t?":"!==t[2]?null:/^[a-zA-Z][a-zA-Z0-9+\-.]*$/.test(t[1])?t[1].toLowerCase():null:"_relative"}};const h={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{enum:["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format <type>"},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean",default:!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string",default:"#cc0000",cli:"-c, --error-color <color>",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:e=>"#"+e},macros:{type:"object",cli:"-m, --macro <def>",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:(e,t)=>(t.push(e),t)},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:e=>Math.max(0,e),cli:"--min-rule-thickness <size>",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{enum:["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number",default:1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:e=>Math.max(0,e),cli:"-s, --max-size <n>",cliProcessor:parseInt},maxExpand:{type:"number",default:1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:e=>Math.max(0,e),cli:"-e, --max-expand <n>",cliProcessor:e=>"Infinity"===e?1/0:parseInt(e)},globalGroup:{type:"boolean",cli:!1}};function c(e){if(e.default)return e.default;const t=e.type,r=Array.isArray(t)?t[0]:t;if("string"!=typeof r)return r.enum[0];switch(r){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{}}}class m{constructor(e){this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0Line truncated
+1 -1
View File
@@ -46,7 +46,7 @@ class TestComposerChipLightboxDelegate:
src = UI.read_text(encoding="utf-8")
# The message-image branch must come first (so _openImgLightbox
# fires for them without falling through to the .attach-thumb check).
msg_branch = "let img = e.target.closest('.msg-media-img');\n if(img){ _openImgLightbox(img.src, img.alt); return; }"
msg_branch = "let img = e.target.closest('.msg-media-img');\n if(img){ _openImgLightbox(img); return; }"
assert msg_branch in src
def test_delegate_excludes_audio_video_chips(self):
+14 -12
View File
@@ -176,14 +176,15 @@ def test_katex_lazy_load_follows_mermaid_pattern():
assert '_katexReady' in UI_JS, '_katexReady flag not found'
def test_katex_js_loaded_from_cdn():
"""KaTeX JS must be loaded from jsdelivr CDN."""
assert 'katex@0.16' in UI_JS, \
'KaTeX JS CDN URL not found in ui.js — expected katex@0.16.x'
def test_katex_js_loaded_from_vendored_asset():
"""KaTeX JS must be loaded from the vendored local asset."""
assert 'static/vendor/katex/0.16.22/katex.min.js' in UI_JS, \
'KaTeX JS vendored URL not found in ui.js — expected local 0.16.22 asset'
assert 'https://cdn.jsdelivr.net/npm/katex@0.16' not in UI_JS
def test_katex_js_has_sri_hash():
"""KaTeX JS CDN tag must have an SRI integrity hash."""
"""KaTeX JS tag must keep an SRI integrity hash for the pinned asset."""
# The hash is in the script.integrity assignment
assert "script.integrity='sha384-" in UI_JS or 'script.integrity="sha384-' in UI_JS, \
'KaTeX JS SRI integrity hash not found in ui.js'
@@ -229,15 +230,16 @@ def test_mermaid_render_failure_removes_temporary_error_dom():
# ── index.html ────────────────────────────────────────────────────────────────
def test_katex_css_in_index_html():
"""KaTeX CSS must be loaded in index.html."""
assert 'katex@0.16' in INDEX, \
'KaTeX CSS CDN link not found in index.html'
"""KaTeX CSS must be loaded from the vendored local asset."""
assert 'static/vendor/katex/0.16.22/katex.min.css' in INDEX, \
'KaTeX CSS vendored link not found in index.html'
assert 'https://cdn.jsdelivr.net/npm/katex@0.16' not in INDEX
def test_katex_css_has_sri_hash():
"""KaTeX CSS link in index.html must have an SRI integrity hash."""
assert 'sha384-5TcZemv2l' in INDEX or 'integrity' in INDEX and 'katex' in INDEX, \
'KaTeX CSS SRI integrity hash not found in index.html'
def test_katex_css_is_pinned_local_asset():
"""KaTeX CSS is pinned by vendored path instead of CDN integrity metadata."""
assert 'static/vendor/katex/0.16.22/katex.min.css' in INDEX, \
'KaTeX CSS local pinned asset not found in index.html'
# ── style.css ─────────────────────────────────────────────────────────────────
+20 -16
View File
@@ -1,26 +1,30 @@
"""Tests for fix #477: KaTeX font-src CSP fix."""
"""Tests for KaTeX font CSP handling.
KaTeX is vendored locally, so the CSP should not need to loosen font-src for a
third-party CDN.
"""
import pathlib
REPO = pathlib.Path(__file__).parent.parent
HELPERS_PY = (REPO / "api" / "helpers.py").read_text(encoding="utf-8")
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
def test_font_src_allows_jsdelivr():
"""font-src must include cdn.jsdelivr.net for KaTeX fonts."""
assert "font-src 'self' data: https://cdn.jsdelivr.net" in HELPERS_PY, (
"api/helpers.py CSP must allow cdn.jsdelivr.net in font-src "
"so KaTeX math rendering fonts load without console errors."
)
def _font_src() -> str:
return HELPERS_PY.split("font-src", 1)[1].split(";", 1)[0]
def test_font_src_still_allows_self_and_data():
"""font-src must still allow self and data: (used by other font assets)."""
assert "'self'" in HELPERS_PY.split("font-src")[1].split(";")[0]
assert "data:" in HELPERS_PY.split("font-src")[1].split(";")[0]
def test_font_src_keeps_self_and_data_without_cdn_font_exception():
"""font-src should stay tight now that KaTeX fonts are local."""
font_src = _font_src()
assert "'self'" in font_src
assert "data:" in font_src
assert "https://cdn.jsdelivr.net" not in font_src
def test_script_src_already_allows_jsdelivr():
"""script-src already allows cdn.jsdelivr.net — font-src should too."""
assert "https://cdn.jsdelivr.net" in HELPERS_PY.split("font-src")[0], (
"script-src should already allow cdn.jsdelivr.net (KaTeX JS)"
)
def test_katex_assets_are_loaded_from_static_vendor_paths():
assert "static/vendor/katex/0.16.22/katex.min.css" in INDEX_HTML
assert "static/vendor/katex/0.16.22/katex.min.js" in UI_JS
assert "https://cdn.jsdelivr.net/npm/katex@0.16.22" not in INDEX_HTML
assert "https://cdn.jsdelivr.net/npm/katex@0.16.22" not in UI_JS
+19
View File
@@ -0,0 +1,19 @@
"""Regression coverage for vendored js-yaml assets."""
from __future__ import annotations
import pathlib
REPO = pathlib.Path(__file__).parent.parent
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
VENDOR_FILE = REPO / "static" / "vendor" / "js-yaml" / "4.1.0" / "js-yaml.min.js"
def test_runtime_loads_vendored_jsyaml_instead_of_cdnjs():
assert "static/vendor/js-yaml/4.1.0/js-yaml.min.js" in UI_JS
assert "https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js" not in UI_JS
def test_vendored_jsyaml_asset_is_present():
assert VENDOR_FILE.is_file()
content = VENDOR_FILE.read_text(encoding="utf-8")
assert "jsyaml" in content
+27
View File
@@ -0,0 +1,27 @@
"""Regression coverage for vendored KaTeX assets."""
from __future__ import annotations
import pathlib
REPO = pathlib.Path(__file__).parent.parent
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
VENDOR_DIR = REPO / "static" / "vendor" / "katex" / "0.16.22"
def test_index_loads_vendored_katex_css_instead_of_cdn():
assert "static/vendor/katex/0.16.22/katex.min.css" in INDEX_HTML
assert "https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" not in INDEX_HTML
def test_runtime_loads_vendored_katex_js_instead_of_cdn():
assert "static/vendor/katex/0.16.22/katex.min.js" in UI_JS
assert "https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js" not in UI_JS
def test_vendored_katex_fonts_are_present_for_local_font_src():
css = (VENDOR_DIR / "katex.min.css").read_text(encoding="utf-8")
assert "url(fonts/KaTeX_Main-Regular.woff2)" in css
assert (VENDOR_DIR / "katex.min.js").is_file()
assert (VENDOR_DIR / "fonts" / "KaTeX_Main-Regular.woff2").is_file()
assert (VENDOR_DIR / "fonts" / "KaTeX_AMS-Regular.woff2").is_file()
+5 -11
View File
@@ -312,11 +312,8 @@ def test_deleted_session_does_not_appear_in_list(cleanup_test_sessions):
assert sid not in ids_after, f"Deleted session {sid} still appears in list -- index not invalidated on delete"
def test_server_delete_invalidates_index(cleanup_test_sessions):
"""R8b: session/delete handler must unlink _index.json.
Static check that the fix is in place.
Sprint 11: handler moved from server.py to api/routes.py -- check both.
"""
def test_server_delete_prunes_session_index(cleanup_test_sessions):
"""session/delete should prune the deleted row without discarding the index."""
src = (REPO_ROOT / "server.py").read_text()
routes_src = (REPO_ROOT / "api" / "routes.py").read_text() if (REPO_ROOT / "api" / "routes.py").exists() else ""
# Find the delete handler in either file
@@ -327,12 +324,9 @@ def test_server_delete_invalidates_index(cleanup_test_sessions):
text.find('if parsed.path == "/api/session/delete":'),
)
if delete_idx >= 0:
# Use 1200 chars to accommodate any validation/guard code added
# before the SESSION_INDEX_FILE.unlink() call (e.g. session_id
# character checks, path traversal guards).
delete_block = text[delete_idx:delete_idx+1200]
assert "SESSION_INDEX_FILE" in delete_block, \
f"{label} session/delete must invalidate SESSION_INDEX_FILE"
delete_block = text[delete_idx:delete_idx+1800]
assert "prune_session_from_index(sid)" in delete_block, \
f"{label} session/delete must prune SESSION_INDEX_FILE"
return
assert False, "session/delete handler not found in server.py or api/routes.py"
+18 -1
View File
@@ -20,7 +20,7 @@ from unittest.mock import patch
import pytest
import api.models as models
from api.models import Session, _write_session_index
from api.models import Session, _write_session_index, prune_session_from_index
@pytest.fixture(autouse=True)
@@ -83,6 +83,23 @@ def test_compact_exposes_last_message_at_from_message_timestamp():
assert compact["last_message_at"] == 200.0
def test_prune_session_from_index_removes_requested_row_only():
index_file = models.SESSION_INDEX_FILE
s_a = _make_session("sess_a", "A", updated_at=100)
s_b = _make_session("sess_b", "B", updated_at=200)
s_a.save()
s_b.save()
prune_session_from_index("sess_a")
index = _read_index(index_file)
ids = [entry["session_id"] for entry in index]
assert ids == ["sess_b"]
assert index_file.exists()
assert s_a.path.exists()
assert s_b.path.exists()
def test_all_sessions_backfills_last_message_at_for_legacy_index_rows():
index_file = models.SESSION_INDEX_FILE
s = Session(
+20 -14
View File
@@ -1,10 +1,9 @@
"""Regression tests for stale session model hydration in the WebUI.
"""Regression tests for session switch model hydration in the WebUI.
Old sessions can persist provider-shaped model IDs such as ``openai/gpt-5.4-mini``
after the active runtime moved to OpenAI Codex ``gpt-5.5``. The first
``loadSession()`` metadata request must ask the backend for the resolved model so
that the composer state cannot briefly use the stale raw value for display or the
next chat-start payload.
after the active runtime moved to OpenAI Codex ``gpt-5.5``. The UI still needs
to repair those stale values, but session switching first paint must not pay the
model catalog cost synchronously.
"""
from pathlib import Path
@@ -30,17 +29,24 @@ def _extract_function(src: str, signature: str) -> str:
raise AssertionError(f"unterminated function body for: {signature}")
def test_load_session_initial_metadata_request_resolves_model_before_state_assignment():
def test_load_session_initial_metadata_request_defers_model_resolution_until_after_state_assignment():
body = _extract_function(SESSIONS_JS, "async function loadSession(sid")
metadata_fetch = "messages=0&resolve_model=1"
stale_metadata_fetch = "messages=0&resolve_model=0"
fast_metadata_fetch = "messages=0&resolve_model=0"
deferred_metadata_fetch = "messages=0&resolve_model=1"
assignment = "S.session=data.session"
assert metadata_fetch in body, (
"loadSession() must resolve model metadata on the initial fetch so stale "
"persisted models like openai/gpt-5.4-mini cannot become active composer state"
assert fast_metadata_fetch in body[: body.index(assignment)], (
"loadSession() first paint must use the metadata fast path so session "
"switching cannot block on cold model catalog hydration"
)
assert stale_metadata_fetch not in body[: body.index(assignment)], (
"loadSession() must not assign S.session from unresolved metadata before the "
"backend has normalized stale model/provider combinations"
assert deferred_metadata_fetch not in body[: body.index(assignment)], (
"loadSession() must not resolve model metadata before assigning S.session; "
"stale model/provider correction belongs to the deferred path"
)
assert "_resolveSessionModelForDisplaySoon(sid)" in body[body.index(assignment):], (
"stale persisted model/provider correction must still happen after first paint"
)
assert body.count("_resolveSessionModelForDisplaySoon(sid)") == 1, (
"deferred model repair should run once per session switch, not once after "
"metadata and again after message hydration"
)
+87
View File
@@ -0,0 +1,87 @@
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SESSIONS_JS = ROOT / "static" / "sessions.js"
STYLE_CSS = ROOT / "static" / "style.css"
def _run_js_ranges(cases):
src = SESSIONS_JS.read_text(encoding="utf-8")
start = src.index("function _sessionSearchRanges")
end = src.index("function _appendHighlightedText", start)
helper = src[start:end]
script = helper + "\nconsole.log(JSON.stringify(cases.map(c => _sessionSearchRanges(c.text, c.query))));"
completed = subprocess.run(
["node", "-e", "const cases = " + json.dumps(cases) + ";\n" + script],
check=True,
text=True,
capture_output=True,
cwd=ROOT,
)
return json.loads(completed.stdout)
def test_sidebar_search_ranges_are_case_insensitive_and_repeated():
ranges = _run_js_ranges([
{"text": "Psalm notes and psalm outline", "query": "PSALM"},
])[0]
assert ranges == [{"start": 0, "end": 5}, {"start": 16, "end": 21}]
def test_sidebar_search_ranges_handle_regex_special_characters():
ranges = _run_js_ranges([
{"text": "Can we search for foo(bar)+baz? safely", "query": "foo(bar)+baz?"},
])[0]
assert ranges == [{"start": 18, "end": 31}]
def test_sidebar_search_ranges_support_multi_word_exact_then_tokens():
exact, tokenized = _run_js_ranges([
{"text": "Parallel TTS Audio Generation Strategy", "query": "tts audio"},
{"text": "Parallel TTS and Audio Strategy", "query": "tts audio"},
])
assert exact == [{"start": 9, "end": 18}]
assert tokenized == [{"start": 9, "end": 12}, {"start": 17, "end": 22}]
def test_sidebar_search_ranges_empty_query_returns_no_ranges():
assert _run_js_ranges([{"text": "Psalm", "query": ""}])[0] == []
def test_session_search_preview_trims_long_body_with_ellipses():
from api.routes import _session_search_preview
body = "Intro " + ("before " * 20) + "generated audio for the Psalm study" + (" after" * 20)
preview = _session_search_preview(body, "Psalm", max_len=92)
assert preview.startswith("...")
assert preview.endswith("...")
assert "Psalm" in preview
assert len(preview) <= 98
def test_session_search_preview_handles_empty_or_unavailable_body():
from api.routes import _session_search_message_text, _session_search_preview
assert _session_search_preview("", "psalm") == ""
assert _session_search_preview(None, "psalm") == ""
assert _session_search_preview("No matching body", "psalm") == ""
assert _session_search_preview("Some body", "") == ""
assert _session_search_message_text({}) == ""
assert _session_search_message_text({"content": [{"type": "text", "text": "Psalm body"}]}) == "Psalm body"
def test_sidebar_search_rendering_uses_safe_dom_helpers():
src = SESSIONS_JS.read_text(encoding="utf-8")
css = STYLE_CSS.read_text(encoding="utf-8")
assert "function _appendHighlightedText" in src
assert ".textContent=source.slice(r.start,r.end)" in src
assert "dangerouslySetInnerHTML" not in src
assert "displayTitle.toLowerCase().includes(searchQueryRaw.toLowerCase())" in src
assert "contentPreview=titleMatched?'':_sessionSearchContentPreview" in src
assert "if(($('sessionSearch').value||'').trim()) _hideSearchPreviewsAfterSelect=true;" in src
assert ".session-search-preview" in css
assert "-webkit-line-clamp:2" in css
@@ -100,5 +100,5 @@ def test_draft_save_does_not_touch_session_updated_at():
src = Path(__file__).parents[1].joinpath("api", "routes.py").read_text(encoding="utf-8")
persist_idx = src.find("s.composer_draft = draft")
assert persist_idx != -1, "could not locate composer draft persist site"
save_idx = src.find("s.save(touch_updated_at=False)", persist_idx)
assert save_idx != -1, "composer draft save must preserve session updated_at"
save_idx = src.find("s.save(touch_updated_at=False, skip_index=True)", persist_idx)
assert save_idx != -1, "composer draft save must preserve session updated_at and skip index churn"
+69 -4
View File
@@ -512,10 +512,12 @@ def test_api_session_reload_drops_stale_cached_user_tail_after_saved_assistant(m
assert handler.response_json["session"]["message_count"] == 2
def test_metadata_fast_path_matches_reconciliation_for_restamped_replays(monkeypatch, tmp_path):
"""#2716 invariant: metadata-only /api/session uses merge_session_messages_append_only
(not a raw state.db COUNT) so restamped replay rows don't make sidebar polling think
the transcript is always newer than the loaded conversation."""
def test_metadata_fast_path_uses_summary_without_full_merge_for_restamped_replays(monkeypatch, tmp_path):
"""Metadata-only /api/session must not full-read and merge transcripts.
It still must not let a restamped replay row make sidebar polling think the
transcript is newer than the loaded sidecar conversation.
"""
import api.routes as routes
sid = "webui_reconcile_metadata_replay"
@@ -535,6 +537,20 @@ def test_metadata_fast_path_matches_reconciliation_for_restamped_replays(monkeyp
{"role": "user", "content": "old user", "timestamp": 1002.0},
],
)
monkeypatch.setattr(
routes,
"get_state_db_session_messages",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
AssertionError("metadata-only loads must not full-read state.db messages")
),
)
monkeypatch.setattr(
routes,
"merge_session_messages_append_only",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
AssertionError("metadata-only loads must not merge full transcripts")
),
)
handler = _GetHandler(f"/api/session?session_id={sid}&messages=0&resolve_model=0")
routes.handle_get(handler, urlparse(handler.path))
@@ -546,6 +562,55 @@ def test_metadata_fast_path_matches_reconciliation_for_restamped_replays(monkeyp
assert session["last_message_at"] == 1001.0
def test_metadata_fast_path_uses_state_db_summary_for_external_growth(monkeypatch, tmp_path):
"""Metadata-only polling can detect real external growth without a full merge."""
import api.routes as routes
sid = "webui_reconcile_metadata_summary_growth"
_install_test_session(
monkeypatch,
tmp_path,
sid,
[
{"role": "user", "content": "old user", "timestamp": 1000.0},
{"role": "assistant", "content": "old assistant", "timestamp": 1001.0},
],
)
_make_state_db(
tmp_path / "state.db",
sid,
[
{"role": "user", "content": "old user", "timestamp": 1000.0},
{"role": "assistant", "content": "old assistant", "timestamp": 1001.0},
{"role": "user", "content": "external user", "timestamp": 1002.0},
{"role": "assistant", "content": "external assistant", "timestamp": 1003.0},
],
)
monkeypatch.setattr(
routes,
"get_state_db_session_messages",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
AssertionError("metadata-only loads must not full-read state.db messages")
),
)
monkeypatch.setattr(
routes,
"merge_session_messages_append_only",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
AssertionError("metadata-only loads must not merge full transcripts")
),
)
handler = _GetHandler(f"/api/session?session_id={sid}&messages=0&resolve_model=0")
routes.handle_get(handler, urlparse(handler.path))
assert handler.status == 200
session = handler.response_json["session"]
assert session["messages"] == []
assert session["message_count"] == 4
assert session["last_message_at"] == 1003.0
def test_state_db_reconciliation_preserves_tool_metadata(monkeypatch, tmp_path):
import api.routes as routes