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 <agent@nesquena-hermes>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-06-22 03:52:19 -07:00
committed by GitHub
parent 148222b40a
commit c5bb1fcaa0
4 changed files with 154 additions and 2 deletions
+6
View File
@@ -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
+47
View File
@@ -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)
+20 -2
View File
@@ -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});
@@ -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