From 5b68d2271b3b2ad279ba9f79fe348b39d2f51a5e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 10 Aug 2026 03:12:58 -0500 Subject: [PATCH] feat(profiles): serve a cross-profile project tree and per-profile usage totals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `projects.tree` answers for the backend's own profile, so the grouped sidebar had nothing to draw once the user asked to see every profile. Run the same authoritative builder once per profile against that profile's state.db and merge the results by folder, so one checkout is one group no matter how many profiles work in it, and the owning profile rides on each session row where the badge and filter can read it. Group totals are summed in SQL rather than over the loaded page — a number that shrank as you scrolled would be worse than no number. Scope the batched sidebar slices while we're here: cron and messaging came back cross-profile unconditionally, which is why a concrete profile showed another profile's Telegram threads and cronjobs. Closes #65710 Closes #42651 Closes #70629 --- hermes_cli/web_routers/profiles.py | 198 ++++++++++++-- hermes_state.py | 29 ++ .../hermes_cli/test_profiles_sidebar_scope.py | 248 ++++++++++++++++++ tui_gateway/project_tree.py | 18 ++ tui_gateway/server.py | 5 + 5 files changed, 479 insertions(+), 19 deletions(-) create mode 100644 tests/hermes_cli/test_profiles_sidebar_scope.py diff --git a/hermes_cli/web_routers/profiles.py b/hermes_cli/web_routers/profiles.py index 31197c9720..145806c6eb 100644 --- a/hermes_cli/web_routers/profiles.py +++ b/hermes_cli/web_routers/profiles.py @@ -241,13 +241,18 @@ def get_profiles_sessions_sidebar( """Batched sidebar session slices — one profile-DB open per refresh. The desktop sidebar needs three source-scoped windows per refresh: recents - (local chats, scoped to the active profile), cron sessions (all profiles), - and messaging-platform sessions (all profiles). Served as three separate - ``/api/profiles/sessions`` calls they reopened every profile's ``state.db`` - three times and re-counted each refresh. This opens each DB once and runs - the three filtered queries together, returning the three windows in one - payload. Read-only and process-light, same row projection and 300s active - heuristic as ``/api/profiles/sessions``. + (local chats), cron sessions, and messaging-platform sessions. Served as + three separate ``/api/profiles/sessions`` calls they reopened every + profile's ``state.db`` three times and re-counted each refresh. This opens + each DB once and runs the three filtered queries together, returning the + three windows in one payload. Read-only and process-light, same row + projection and 300s active heuristic as ``/api/profiles/sessions``. + + ``recents_profile`` scopes the whole payload, not just recents. Cron and + messaging used to come back cross-profile unconditionally, which is what + made a concrete profile show another profile's Telegram threads and + cronjobs (#65710, #42651, #70629) — the sidebar has one scope, so every + slice answers to it, and ``all`` is how the caller asks for everything. The caller passes the source taxonomy (``recents_exclude`` / ``messaging_exclude`` CSV, ``source=cron`` is implicit) so this stays @@ -257,8 +262,6 @@ def get_profiles_sessions_sidebar( """ from hermes_cli import profiles as profiles_mod - # cron + messaging are cross-profile; recents is scoped to recents_profile. - # Scan every profile once regardless (each DB opened a single time). try: infos = profiles_mod.list_profiles() targets: List[Tuple[str, Path]] = [(info.name, info.path) for info in infos] @@ -280,6 +283,7 @@ def get_profiles_sessions_sidebar( cron_rows: List[Dict[str, Any]] = [] messaging_rows: List[Dict[str, Any]] = [] recents_truncated: Dict[str, bool] = {} + profile_totals: Dict[str, Dict[str, float]] = {} errors: List[Dict[str, str]] = [] now = time.time() @@ -314,6 +318,8 @@ def get_profiles_sessions_sidebar( ) for name, home in targets: + if recents_scope != "all" and name != recents_scope: + continue db_path = Path(home) / "state.db" if not db_path.exists(): continue @@ -327,16 +333,19 @@ def get_profiles_sessions_sidebar( errors.append({"profile": name, "error": str(exc)}) continue try: - if recents_scope == "all" or name == recents_scope: - profile_rows = _slice(db, exclude=recents_exclude_list, cap=recents_cap) - # A full window means more rows remain on disk. That is all the - # sidebar's "load more" needs, and unlike an exact COUNT(*) per - # profile per refresh it costs nothing beyond the rows already - # read. Discount pinned back-fills — they arrive past the LIMIT - # and would otherwise fake a full page on a short list. - unpinned_count = sum(1 for s in profile_rows if not s.get("pinned")) - recents_truncated[name] = unpinned_count >= recents_cap - recents_rows.extend(_tag(profile_rows, name)) + profile_rows = _slice(db, exclude=recents_exclude_list, cap=recents_cap) + # A full window means more rows remain on disk. That is all the + # sidebar's "load more" needs, and unlike an exact COUNT(*) per + # profile per refresh it costs nothing beyond the rows already + # read. Discount pinned back-fills — they arrive past the LIMIT + # and would otherwise fake a full page on a short list. + unpinned_count = sum(1 for s in profile_rows if not s.get("pinned")) + recents_truncated[name] = unpinned_count >= recents_cap + recents_rows.extend(_tag(profile_rows, name)) + # Aggregated in SQL rather than over the window above: the window is + # a page, and a total that shrank when you scrolled would be worse + # than no total at all. + profile_totals[name] = db.usage_totals() cron_rows.extend(_tag(_slice(db, source="cron", cap=cron_cap), name)) messaging_rows.extend( _tag(_slice(db, exclude=messaging_exclude_list, cap=messaging_cap), name) @@ -363,6 +372,7 @@ def get_profiles_sessions_sidebar( "recents": { "sessions": _window(recents_rows, recents_cap), "profiles_truncated": recents_truncated, + "profiles_usage": profile_totals, }, "cron": {"sessions": _window(cron_rows, cron_cap)}, "messaging": { @@ -373,6 +383,156 @@ def get_profiles_sessions_sidebar( } +def _merge_by_id(into: Dict[str, Dict[str, Any]], entries: List[Dict[str, Any]], child_key: str) -> None: + """Fold ``entries`` into ``into`` by id, recursing through one child list. + + Repos merge their lanes, lanes merge their sessions. Counts add up and the + newest activity wins; everything else is first-writer, since the entries + describe the same path either way. + """ + for entry in entries: + existing = into.get(entry["id"]) + if existing is None: + into[entry["id"]] = entry + continue + if child_key == "sessions": + existing["sessions"].extend(entry.get("sessions") or []) + else: + children: Dict[str, Dict[str, Any]] = {c["id"]: c for c in existing.get(child_key) or []} + _merge_by_id(children, entry.get(child_key) or [], "sessions") + existing[child_key] = list(children.values()) + if "sessionCount" in existing: + existing["sessionCount"] = (existing.get("sessionCount") or 0) + (entry.get("sessionCount") or 0) + + +def _merge_profile_tree( + merged: Dict[str, Dict[str, Any]], + projects: List[Dict[str, Any]], + profile: str, + preview_limit: int, +) -> None: + """Fold one profile's projects into the shared tree, keyed by folder. + + The same checkout in two profiles is one group, as is ``__no_project__``, + which every profile has and which would otherwise put a "Home" on screen per + profile. Keying on the path rather than the id also folds a profile's + declared project (``p_``) together with the auto entry another profile + grows for the same folder. Sessions carry the owning profile instead, which + is what the row badge and the profile filter read; a group header never + claims a single owner. + """ + for project in projects: + for lane in (repo for r in project.get("repos") or [] for repo in r.get("groups") or []): + for session in lane.get("sessions") or []: + session["profile"] = profile + session["is_default_profile"] = profile == "default" + for session in project.get("previewSessions") or []: + session["profile"] = profile + session["is_default_profile"] = profile == "default" + + key = project.get("path") or project["id"] + existing = merged.get(key) + if existing is None: + merged[key] = project + continue + + # A declared project carries the label, color and icon the user chose, + # so it wins the identity when it meets another profile's auto entry. + if existing.get("isAuto") and not project.get("isAuto"): + existing, project = project, existing + merged[key] = existing + + repos: Dict[str, Dict[str, Any]] = {r["id"]: r for r in existing.get("repos") or []} + _merge_by_id(repos, project.get("repos") or [], "groups") + existing["repos"] = list(repos.values()) + existing["sessionCount"] = (existing.get("sessionCount") or 0) + (project.get("sessionCount") or 0) + existing["totalTokens"] = (existing.get("totalTokens") or 0) + (project.get("totalTokens") or 0) + existing["totalCostUsd"] = (existing.get("totalCostUsd") or 0) + (project.get("totalCostUsd") or 0) + existing["lastActive"] = max(existing.get("lastActive") or 0, project.get("lastActive") or 0) + previews = (existing.get("previewSessions") or []) + (project.get("previewSessions") or []) + previews.sort(key=lambda s: s.get("last_active") or s.get("started_at") or 0, reverse=True) + existing["previewSessions"] = previews[:preview_limit] + + +@sessions_router.get("/api/profiles/projects/tree") +def get_profiles_projects_tree(preview_limit: int = 3, session_limit: int = 2000): + """Project tree for every profile at once, for the all-profiles sidebar. + + ``projects.tree`` over JSON-RPC answers for the backend's own profile, so + the grouped sidebar had nothing to draw once the user asked for all of + them. This runs the same authoritative builder once per profile against + that profile's ``state.db``, scoping the rest of its inputs — projects.db, + the repo-scan policy, the HERMES_HOME junk filters — through the + context-local home override the profile-scoped writers already use. + + Projects merge by id across profiles, so a group stands for a checkout + rather than a checkout-and-owner, and the profile shows up per row where + the filter can act on it. + + Discovery is off. A repo with zero sessions is the same repo in every + profile, so folding the disk scan in would multiply empty lanes by the + profile count — and it is the one part of the builder that writes + (policy reconciliation), which this read-only fan-out should not do to a + profile the user is not driving. + """ + from hermes_cli import profiles as profiles_mod + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + from tui_gateway import server as gateway_server + + try: + targets: List[Tuple[str, Path]] = [ + (info.name, info.path) for info in profiles_mod.list_profiles() + ] + except Exception: + _log.exception("GET /api/profiles/projects/tree: list_profiles failed") + targets = [] + if not targets: + targets.append(("default", profiles_mod.get_profile_dir("default"))) + + merged: Dict[str, Dict[str, Any]] = {} + scoped_session_ids: List[str] = [] + errors: List[Dict[str, str]] = [] + + for name, home in targets: + db_path = Path(home) / "state.db" + if not db_path.exists(): + continue + try: + db = _open_session_db_at_path(db_path, read_only=True) + except Exception as exc: + _warn_profile_read_error(name, exc) + errors.append({"profile": name, "error": str(exc)}) + continue + + token = set_hermes_home_override(str(home)) + try: + tree, _active_id = gateway_server._build_project_tree( + db, + preview_limit=preview_limit, + hydrate=False, + session_limit=session_limit, + include_discovered=False, + ) + _merge_profile_tree(merged, tree["projects"], name, preview_limit) + scoped_session_ids.extend(tree["scoped_session_ids"]) + except Exception as exc: + _warn_profile_read_error(name, exc) + errors.append({"profile": name, "error": str(exc)}) + finally: + reset_hermes_home_override(token) + db.close() + + projects = sorted(merged.values(), key=lambda p: p.get("lastActive") or 0, reverse=True) + return { + "projects": projects, + # Ownership is per profile, so no single project is "the active one" + # here; the desktop only reads active_id to bias its overview sort. + "active_id": None, + "scoped_session_ids": scoped_session_ids, + "errors": errors, + } + + # `gh pr create` prints the PR url and nothing else, so a tool result whose # whole output IS a PR url means this session opened that PR. Anything looser — # a url inside prose, a `gh pr view` payload, an issue link — is a session diff --git a/hermes_state.py b/hermes_state.py index 27aaee7a4b..18c8e1aec7 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -6886,6 +6886,35 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) ) _session_compact_cols_sql: Optional[str] = None + def usage_totals(self, *, min_message_count: int = 1, include_archived: bool = False) -> Dict[str, float]: + """Tokens and spend across this store, as one aggregate. + + The sidebar shows a profile's totals beside a page of its sessions, so + summing the rows it happens to have loaded would report a fraction of + the truth and shrink as paging changed. SQLite adds the columns up over + every row instead, at the cost of one scan. + + Spend is the billed figure when the provider returned one and the + estimate otherwise — the same precedence a single row renders. + """ + where = ["parent_session_id IS NULL", "message_count >= ?"] + params: List[Any] = [min_message_count] + if not include_archived: + where.append("COALESCE(archived, 0) = 0") + + with self._read_ctx() as conn: + row = conn.execute( + f""" + SELECT COALESCE(SUM(COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)), 0), + COALESCE(SUM(COALESCE(actual_cost_usd, estimated_cost_usd, 0)), 0) + FROM sessions + WHERE {' AND '.join(where)} + """, + params, + ).fetchone() + + return {"tokens": int(row[0] or 0), "cost_usd": float(row[1] or 0.0)} + def list_sessions_rich( self, source: str = None, diff --git a/tests/hermes_cli/test_profiles_sidebar_scope.py b/tests/hermes_cli/test_profiles_sidebar_scope.py new file mode 100644 index 0000000000..75309fbd00 --- /dev/null +++ b/tests/hermes_cli/test_profiles_sidebar_scope.py @@ -0,0 +1,248 @@ +"""The sidebar's profile scope, across both endpoints that serve it. + +Two behaviors that only show up with more than one profile on disk: + +* ``/api/profiles/sessions/sidebar`` must answer to one scope for all three of + its slices. Cron and messaging ignoring it is what made a concrete profile + show another profile's Telegram threads and cronjobs (#65710, #42651, + #70629). +* ``/api/profiles/projects/tree`` must build each profile's tree from that + profile's own state.db AND its own projects.db, and hand back ids that can + coexist in one list. +""" + +import pytest + + +@pytest.fixture +def profiles_on_disk(tmp_path, monkeypatch, _isolate_hermes_home): + """An isolated default home plus one named profile, each with a state.db.""" + from hermes_cli import profiles + from hermes_constants import get_hermes_home + + default_home = get_hermes_home() + profiles_root = default_home / "profiles" + worker_home = profiles_root / "worker" + + for home in (default_home, worker_home): + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text("{}\n", encoding="utf-8") + + monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home) + monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root) + + return {"default": default_home, "worker": worker_home} + + +@pytest.fixture +def client(monkeypatch, profiles_on_disk): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + + import hermes_state + from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN, app + from hermes_constants import get_hermes_home + + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db") + c = TestClient(app) + c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN + + return c + + +def _seed_session(home, session_id, *, source, cwd=None, tokens=None, cost=None): + """One session with a message, so it clears the sidebar's min_messages=1. + + ``cwd`` is what attaches it to a project — without one it lands in Home. + ``tokens`` is an (input, output) pair; both it and ``cost`` are written + straight to the row, the shape a finished turn leaves behind. + """ + import sqlite3 + + from hermes_state import SessionDB + + db = SessionDB(db_path=home / "state.db") + try: + db.create_session(session_id, source=source, cwd=str(cwd) if cwd else None) + db.append_message(session_id=session_id, role="user", content="hi") + finally: + db.close() + + if tokens is None and cost is None: + return + + conn = sqlite3.connect(home / "state.db") + try: + conn.execute( + "UPDATE sessions SET input_tokens = ?, output_tokens = ?, estimated_cost_usd = ? WHERE id = ?", + (*(tokens or (0, 0)), cost or 0.0, session_id), + ) + conn.commit() + finally: + conn.close() + + +def _seed_project(home, name, folder): + from hermes_cli import projects_db + + with projects_db.connect_closing(db_path=home / "projects.db") as conn: + return projects_db.create_project(conn, name=name, folders=[str(folder)]) + + +def _slice_ids(payload, slice_name): + return {row["id"] for row in payload[slice_name]["sessions"]} + + +class TestSidebarScope: + + def test_concrete_profile_sees_only_its_own_slices(self, client, profiles_on_disk): + _seed_session(profiles_on_disk["default"], "default-chat", source="cli") + _seed_session(profiles_on_disk["default"], "default-cron", source="cron") + _seed_session(profiles_on_disk["default"], "default-telegram", source="telegram") + _seed_session(profiles_on_disk["worker"], "worker-chat", source="cli") + _seed_session(profiles_on_disk["worker"], "worker-cron", source="cron") + _seed_session(profiles_on_disk["worker"], "worker-telegram", source="telegram") + + payload = client.get( + "/api/profiles/sessions/sidebar", + params={"recents_profile": "worker", "recents_exclude": "cron,telegram", "messaging_exclude": "cli,cron"}, + ).json() + + assert payload["errors"] == [] + assert _slice_ids(payload, "recents") == {"worker-chat"} + # The bug: these two used to come back with the default profile's rows + # folded in, whatever scope the sidebar asked for. + assert _slice_ids(payload, "cron") == {"worker-cron"} + assert _slice_ids(payload, "messaging") == {"worker-telegram"} + + def test_all_scope_still_spans_every_profile(self, client, profiles_on_disk): + _seed_session(profiles_on_disk["default"], "default-telegram", source="telegram") + _seed_session(profiles_on_disk["worker"], "worker-telegram", source="telegram") + + payload = client.get( + "/api/profiles/sessions/sidebar", + params={"recents_profile": "all", "messaging_exclude": "cli,cron"}, + ).json() + + assert _slice_ids(payload, "messaging") == {"default-telegram", "worker-telegram"} + assert {row["profile"] for row in payload["messaging"]["sessions"]} == {"default", "worker"} + + +class TestCrossProfileProjectTree: + + def test_one_folder_worked_in_by_two_profiles_is_one_project(self, client, profiles_on_disk, tmp_path): + # A folder is a folder no matter who opened it. Two profiles working the + # same checkout is the normal case (that's the point of profiles), so it + # heads ONE group carrying both their sessions — not one group each. + shared = tmp_path / "repos" / "shared" + shared.mkdir(parents=True) + + for name, home in profiles_on_disk.items(): + _seed_session(home, f"{name}-chat", source="cli", cwd=shared) + _seed_project(home, "Shared", shared) + + payload = client.get("/api/profiles/projects/tree").json() + + assert payload["errors"] == [] + + declared = [project for project in payload["projects"] if not project["isNoProject"]] + assert [project["path"] for project in declared] == [str(shared)] + assert declared[0]["sessionCount"] == 2 + + def test_group_totals_add_up_the_sessions_the_group_counts(self, client, profiles_on_disk, tmp_path): + # A header total is only meaningful if it covers exactly what the header + # says it counts — a project's totals span every profile working it, the + # same set `sessionCount` reports. + shared = tmp_path / "repos" / "shared" + shared.mkdir(parents=True) + + for name, home in profiles_on_disk.items(): + _seed_session(home, f"{name}-chat", source="cli", cwd=shared, tokens=(100, 20), cost=0.25) + _seed_project(home, "Shared", shared) + + payload = client.get("/api/profiles/projects/tree").json() + project = next(p for p in payload["projects"] if not p["isNoProject"]) + + assert project["sessionCount"] == 2 + assert project["totalTokens"] == 240 + assert project["totalCostUsd"] == pytest.approx(0.5) + + def test_profile_usage_covers_sessions_past_the_window(self, client, profiles_on_disk): + # The whole point of aggregating in SQL: the total must not be a sum of + # whichever page the sidebar happens to have asked for. + for index in range(3): + _seed_session( + profiles_on_disk["worker"], f"worker-{index}", source="cli", tokens=(10, 5), cost=1.5 + ) + + payload = client.get( + "/api/profiles/sessions/sidebar", params={"recents_profile": "all", "recents_limit": 1} + ).json() + + assert payload["recents"]["profiles_usage"]["worker"] == { + "cost_usd": pytest.approx(4.5), + "tokens": 45, + } + + def test_home_is_one_bucket_across_profiles(self, client, profiles_on_disk): + # Every profile builds its own unowned-sessions bucket. Merging by id is + # what keeps the sidebar from stacking N identical "Home" rows. + for name, home in profiles_on_disk.items(): + _seed_session(home, f"{name}-chat", source="cli") + + payload = client.get("/api/profiles/projects/tree").json() + + homes = [project for project in payload["projects"] if project["isNoProject"]] + assert len(homes) == 1 + assert homes[0]["sessionCount"] == 2 + + def test_each_profile_contributes_its_own_projects_db(self, client, profiles_on_disk, tmp_path): + """Proves the per-profile scoping, not just that two trees got merged. + + The builder reads projects.db, the repo-scan policy and the junk + filters through ``get_hermes_home()``. If the fan-out failed to rebind + it per profile, every tree would come back describing whichever home + the process happens to be running as. + """ + for name in profiles_on_disk: + (tmp_path / "repos" / f"only-{name}").mkdir(parents=True) + _seed_session(profiles_on_disk[name], f"{name}-chat", source="cli") + _seed_project(profiles_on_disk[name], f"Only {name}", tmp_path / "repos" / f"only-{name}") + + payload = client.get("/api/profiles/projects/tree").json() + + labels = {project["label"] for project in payload["projects"] if not project["isNoProject"]} + + assert labels == {"Only default", "Only worker"} + + def test_a_profile_that_cannot_be_read_does_not_sink_the_rest( + self, client, profiles_on_disk, tmp_path, monkeypatch + ): + (tmp_path / "repos" / "healthy").mkdir(parents=True) + # A state.db has to exist for a profile to be visited at all. + for name, home in profiles_on_disk.items(): + _seed_session(home, f"{name}-chat", source="cli") + _seed_project(profiles_on_disk["default"], "Healthy", tmp_path / "repos" / "healthy") + + from tui_gateway import server as gateway_server + + real_build = gateway_server._build_project_tree + + def explode_for_worker(db, **kwargs): + from hermes_constants import get_hermes_home + + if get_hermes_home().name == "worker": + raise RuntimeError("worker store is unreadable") + + return real_build(db, **kwargs) + + monkeypatch.setattr(gateway_server, "_build_project_tree", explode_for_worker) + + payload = client.get("/api/profiles/projects/tree").json() + + assert [error["profile"] for error in payload["errors"]] == ["worker"] + # The healthy profile's tree still lands; only the broken one drops out. + assert "Healthy" in [project["label"] for project in payload["projects"]] + assert [project["sessionCount"] for project in payload["projects"] if project["isNoProject"]] == [1] diff --git a/tui_gateway/project_tree.py b/tui_gateway/project_tree.py index 2991124a63..cd5c966a55 100644 --- a/tui_gateway/project_tree.py +++ b/tui_gateway/project_tree.py @@ -505,6 +505,15 @@ def _project_for_session(session: dict, index: _FolderIndex, resolve: Optional[R # --------------------------------------------------------------------------- +def _session_cost(session: dict) -> float: + """A session's spend, billed if the provider reported it, else estimated.""" + for key in ("actual_cost_usd", "estimated_cost_usd"): + value = session.get(key) + if value: + return float(value) + return 0.0 + + def _project_node( *, pid: str, @@ -514,6 +523,7 @@ def _project_node( session_count: int, last_active: float, preview_sessions: list[dict], + sessions: Optional[list[dict]] = None, color: Any = None, icon: Any = None, is_auto: bool = False, @@ -529,6 +539,11 @@ def _project_node( "isNoProject": is_no_project, "sessionCount": session_count, "lastActive": last_active, + # Totals over the same sessions `sessionCount` counts, so a project's + # header can add up what its rows show. The window the caller loaded is + # the whole truth either way — count and totals can't disagree. + "totalTokens": sum((s.get("input_tokens") or 0) + (s.get("output_tokens") or 0) for s in sessions or []), + "totalCostUsd": sum(_session_cost(s) for s in sessions or []), "repos": repos, "previewSessions": preview_sessions, } @@ -612,6 +627,7 @@ def build_tree( session_count=len(psessions), last_active=_last_active(psessions), preview_sessions=_previews(psessions), + sessions=psessions, ) ) @@ -693,6 +709,7 @@ def build_tree( session_count=repo_node["sessionCount"], last_active=_last_active(auto_sessions), preview_sessions=_previews(auto_sessions), + sessions=auto_sessions, is_auto=True, ) ) @@ -761,6 +778,7 @@ def build_tree( session_count=len(homeless), last_active=_last_active(homeless), preview_sessions=_previews(homeless), + sessions=homeless, is_no_project=True, ), ) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 874e8d3188..48585a715c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -11928,6 +11928,11 @@ def _project_tree_row(r: dict) -> dict: "tool_call_count": r.get("tool_call_count") or 0, "input_tokens": r.get("input_tokens") or 0, "output_tokens": r.get("output_tokens") or 0, + # Cost is one of the fields SidebarSessionRow renders, so a lane row has + # to carry it too — without it, switching Show → cost on filled every + # figure in Recents and left the same sessions blank under a project. + "actual_cost_usd": r.get("actual_cost_usd"), + "estimated_cost_usd": r.get("estimated_cost_usd"), "model": r.get("model"), "is_active": False, "cwd": r.get("cwd"),