feat(profiles): serve a cross-profile project tree and per-profile usage totals

`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
This commit is contained in:
Brooklyn Nicholson
2026-08-10 03:12:58 -05:00
parent 56dc01d904
commit 5b68d2271b
5 changed files with 479 additions and 19 deletions
+18
View File
@@ -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,
),
)