From c5bb1fcaa01ac31bd12ff62ec433ec556c706132 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 22 Jun 2026 03:52:19 -0700 Subject: [PATCH] stage #4688 (franksong2702): running-first session ordering in sidebar (#4699) Overlays live pending_started_at/updated_at/last_message_at onto cached /api/sessions rows (when newer), sorts active/running sessions first then by freshest effective timestamp, applies the same running-first order in the frontend sidebar renderer, and groups date buckets with the same runtime-aware timestamp (no stale header on resumed running sessions). routes.py + sessions.js + 81-line test. Composes with #4680 cache-freeze + #4597 (98 session-list tests pass). Co-authored-by: nesquena-hermes Co-authored-by: franksong2702 --- CHANGELOG.md | 6 ++ api/routes.py | 47 ++++++++++++ static/sessions.js | 22 +++++- tests/test_session_list_long_history_perf.py | 81 ++++++++++++++++++++ 4 files changed, 154 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77f7ec591..02c53bc93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.587] — 2026-06-22 — Release UT (running-first session ordering in sidebar) + +### Fixed + +- **Running sessions stay grouped at the top of the sidebar.** A completed conversation could sit between actively-running sessions until the session-list cache rebuilt, because the cached `/api/sessions` payload was sorted before live runtime state was overlaid. The list now overlays live `pending_started_at` / `updated_at` / `last_message_at` onto cached rows and sorts active/running sessions first (then by freshest effective timestamp), applies the same running-first ordering in the frontend renderer, and groups date buckets with the same runtime-aware timestamp so a resumed running session doesn't carry a stale date header. Thanks @franksong2702. (#4688) + ## [v0.51.586] — 2026-06-22 — Release US (eliminate iOS button tap delay) ### Fixed diff --git a/api/routes.py b/api/routes.py index 5f9ad6ea7..cc794da40 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1844,12 +1844,59 @@ def _session_list_cache_overlay_runtime_rows(rows: list[dict]) -> list[dict]: item["has_pending_user_message"] = bool( getattr(live, "pending_user_message", None) ) + for key in ("pending_started_at", "updated_at", "last_message_at"): + current = _session_list_row_numeric_value(item.get(key)) + raw_live_value = getattr(live, key, None) + live_value = _session_list_row_numeric_value(raw_live_value) + if live_value > current: + item[key] = raw_live_value stream_id = item.get("active_stream_id") item["is_streaming"] = bool(stream_id and stream_id in active_stream_ids) overlaid.append(item) + overlaid.sort(key=_session_list_runtime_sort_key, reverse=True) return overlaid +def _session_list_row_numeric_value(value) -> float: + try: + numeric = float(value or 0) + except (TypeError, ValueError): + return 0.0 + return numeric if numeric > 0 else 0.0 + + +def _session_list_row_timestamp(row: dict) -> float: + if not isinstance(row, dict): + return 0.0 + # Match the frontend `_sessionSortTimestampMs` semantics exactly (#4688 review): + # the idle base is the FIRST truthy of last_message_at -> updated_at -> created_at + # (NOT a flat max over all of them — a renamed/metadata-touched idle chat bumps + # updated_at without new messages and must not outrank a newer chatted session), + # then pending_started_at is overlaid only as the runtime promotion. + base = 0.0 + for key in ("last_message_at", "updated_at", "created_at"): + base = _session_list_row_numeric_value(row.get(key)) + if base > 0: + break + pending = _session_list_row_numeric_value(row.get("pending_started_at")) + return max(base, pending) + + +def _session_list_row_is_runtime_active(row: dict) -> bool: + if not isinstance(row, dict): + return False + if row.get("is_streaming"): + return True + return bool(row.get("active_stream_id") and row.get("has_pending_user_message")) + + +def _session_list_runtime_sort_key(row: dict) -> tuple[int, float]: + return ( + 1 if _session_list_row_is_runtime_active(row) else 0, + _session_list_row_timestamp(row), + ) + + def _session_list_cache_claim_rebuild(key: tuple) -> tuple[threading.Event, bool]: with _SESSIONS_CACHE_LOCK: current = _SESSIONS_CACHE_INFLIGHT.get(key) diff --git a/static/sessions.js b/static/sessions.js index 6322ec087..062fd6396 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -4464,6 +4464,24 @@ function _sessionTimestampMs(session) { return Number.isFinite(raw) ? raw * 1000 : 0; } +function _sessionSortTimestampMs(session) { + const base = _sessionTimestampMs(session); + const pending = Number(session && session.pending_started_at); + const pendingMs = Number.isFinite(pending) ? pending * 1000 : 0; + return Math.max(base, pendingMs); +} + +function _sessionRunningSortRank(session) { + if(_isSessionEffectivelyStreaming(session)) return 1; + return session && session.active_stream_id && session.has_pending_user_message ? 1 : 0; +} + +function _sessionSidebarSortCompare(a, b) { + const activeDelta = _sessionRunningSortRank(b) - _sessionRunningSortRank(a); + if(activeDelta) return activeDelta; + return _sessionSortTimestampMs(b) - _sessionSortTimestampMs(a); +} + function _serverNowMs() { // Compensate for clock skew between client and server (issue #1144). // Returns an approximation of the current server time in ms. @@ -5557,7 +5575,7 @@ function renderSessionListFromCache(){ empty.textContent=_activeProject===NO_PROJECT_FILTER?'No unassigned sessions.':'No sessions in this project yet.'; list.appendChild(empty); } - const orderedSessions=[...sessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a)); + const orderedSessions=[...sessions].sort(_sessionSidebarSortCompare); // Separate pinned from unpinned const pinned=orderedSessions.filter(s=>s.pinned); const unpinned=orderedSessions.filter(s=>!s.pinned); @@ -5572,7 +5590,7 @@ function renderSessionListFromCache(){ let curLabel=null,curItems=[]; if(pinned.length) groups.push({label:'\u2605 Pinned',items:pinned,isPinned:true}); for(const s of unpinned){ - const ts=_sessionTimestampMs(s); + const ts=_sessionSortTimestampMs(s); const label=_sessionTimeBucketLabel(ts, now); if(label!==curLabel){ if(curItems.length) groups.push({label:curLabel,items:curItems}); diff --git a/tests/test_session_list_long_history_perf.py b/tests/test_session_list_long_history_perf.py index 63d83a148..d9e94a16e 100644 --- a/tests/test_session_list_long_history_perf.py +++ b/tests/test_session_list_long_history_perf.py @@ -1,6 +1,7 @@ import io import json import pathlib +from types import SimpleNamespace from urllib.parse import urlparse import api.profiles as profiles @@ -175,3 +176,83 @@ def test_session_list_fetch_adds_include_archived_only_when_toggle_is_on(): assert "_archivedWebuiCount" in src assert "sessData.archived_webui_count ?? sessData.archived_count ?? 0" in src assert "archived_webui_count" in src + + +def test_sessions_api_runtime_overlay_sorts_active_rows_first(monkeypatch): + payload = { + "sessions": [ + { + "session_id": "newer-complete", + "title": "Newer complete", + "updated_at": 300, + "last_message_at": 300, + }, + { + "session_id": "older-running", + "title": "Older running", + "updated_at": 100, + "last_message_at": 100, + }, + { + "session_id": "old-complete", + "title": "Old complete", + "updated_at": 50, + "last_message_at": 50, + }, + ], + "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, + } + live = SimpleNamespace( + active_stream_id="stream-running", + pending_user_message="go", + pending_started_at="400", + updated_at="400", + last_message_at="400", + ) + + monkeypatch.setattr(routes, "_active_stream_ids", lambda: {"stream-running"}) + with routes.LOCK: + previous = routes.SESSIONS.get("older-running") + routes.SESSIONS["older-running"] = live + try: + body = routes._session_list_payload_to_response(payload) + finally: + with routes.LOCK: + if previous is None: + routes.SESSIONS.pop("older-running", None) + else: + routes.SESSIONS["older-running"] = previous + + assert [row["session_id"] for row in body["sessions"]] == [ + "older-running", + "newer-complete", + "old-complete", + ] + assert body["sessions"][0]["is_streaming"] is True + assert body["sessions"][0]["updated_at"] == "400" + + +def test_frontend_session_list_sorts_effective_streaming_rows_first(): + src = (pathlib.Path(__file__).parent.parent / "static" / "sessions.js").read_text(encoding="utf-8") + + assert "function _sessionSidebarSortCompare(a, b)" in src + assert "function _sessionRunningSortRank(session)" in src + assert "_isSessionEffectivelyStreaming(session)" in src + assert "session.active_stream_id && session.has_pending_user_message" in src + assert "const orderedSessions=[...sessions].sort(_sessionSidebarSortCompare);" in src + + +def test_frontend_session_date_buckets_use_runtime_sort_timestamp(): + src = (pathlib.Path(__file__).parent.parent / "static" / "sessions.js").read_text(encoding="utf-8") + loop_start = src.index("for(const s of unpinned){") + loop_body = src[loop_start:src.index("if(curItems.length) groups.push", loop_start)] + + assert "const ts=_sessionSortTimestampMs(s);" in loop_body + assert "_sessionTimeBucketLabel(ts, now)" in loop_body