mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 20:50:18 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user