From 5b68d2271b3b2ad279ba9f79fe348b39d2f51a5e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 10 Aug 2026 03:12:58 -0500 Subject: [PATCH 1/6] 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"), From 8fdb92f44936c0137bbdcade7d8b5d280347e1dd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 10 Aug 2026 03:13:04 -0500 Subject: [PATCH 2/6] fix(desktop): hoist the sidebar's sort key out of the flat list The sort key was applied where the flat recents list is assembled, so it did nothing at all once rows moved into groups: picking "cost" while grouped by project or profile left every lane in the order the backend sent it. Rank in a store instead, above any one view, so a grouped surface can order the rows it owns by the same key. --- .../src/app/chat/sidebar/order.test.ts | 18 +++++ apps/desktop/src/app/chat/sidebar/order.ts | 9 +++ apps/desktop/src/store/sidebar-sort.test.ts | 65 +++++++++++++++++++ apps/desktop/src/store/sidebar-sort.ts | 62 ++++++++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 apps/desktop/src/store/sidebar-sort.test.ts create mode 100644 apps/desktop/src/store/sidebar-sort.ts diff --git a/apps/desktop/src/app/chat/sidebar/order.test.ts b/apps/desktop/src/app/chat/sidebar/order.test.ts index 6a12d8e086..37a4b36df9 100644 --- a/apps/desktop/src/app/chat/sidebar/order.test.ts +++ b/apps/desktop/src/app/chat/sidebar/order.test.ts @@ -6,6 +6,7 @@ import type { SessionInfo } from '@/types/hermes' import { orderByIds, orderRowsWithinGroups, + rankSessions, reconcileOrderIds, reorderableRowIds, resolveManualSessionOrderIds, @@ -64,6 +65,23 @@ describe('orderByIds', () => { }) }) +describe('rankSessions', () => { + const sessions = [{ id: 'newest' }, { id: 'middle' }, { id: 'oldest' }] + + it('leaves the lane alone when the sidebar is on its default sort', () => { + expect(rankSessions(sessions)).toBe(sessions) + expect(rankSessions(sessions, [])).toBe(sessions) + }) + + it('applies the active sort key to a lane the flat list never renders', () => { + expect(rankSessions(sessions, ['oldest', 'newest', 'middle']).map(s => s.id)).toEqual([ + 'oldest', + 'newest', + 'middle' + ]) + }) +}) + describe('reconcileOrderIds', () => { it('returns empty for no current ids', () => { expect(reconcileOrderIds([], ['a'])).toEqual([]) diff --git a/apps/desktop/src/app/chat/sidebar/order.ts b/apps/desktop/src/app/chat/sidebar/order.ts index 9998243722..c65a53dc03 100644 --- a/apps/desktop/src/app/chat/sidebar/order.ts +++ b/apps/desktop/src/app/chat/sidebar/order.ts @@ -103,6 +103,15 @@ export function orderByIds(items: T[], getId: (item: T) => string, orderIds: return [...newer, ...ordered, ...older] } +/** + * Apply the active sort key (as an id order) to a set of session rows, leaving + * them in the order they came in when nothing is ranked. Grouped views call + * this on their own lane so a sort key reaches rows the flat list never renders. + */ +export function rankSessions(sessions: T[], rankIds?: string[]): T[] { + return rankIds?.length ? orderByIds(sessions, session => session.id, rankIds) : sessions +} + /** Reconcile a persisted order against the live id set. */ export function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] { if (!currentIds.length) { diff --git a/apps/desktop/src/store/sidebar-sort.test.ts b/apps/desktop/src/store/sidebar-sort.test.ts new file mode 100644 index 0000000000..1703c5b8f2 --- /dev/null +++ b/apps/desktop/src/store/sidebar-sort.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import type { SessionInfo } from '@/hermes' + +import { resetSidebarView, setSidebarOrdering } from './layout' +import { $sessions } from './session' +import { $sidebarSessionRankIds } from './sidebar-sort' + +const session = (id: string, fields: Partial) => + ({ id, input_tokens: 0, output_tokens: 0, started_at: 0, ...fields }) as SessionInfo + +beforeEach(() => { + resetSidebarView() + $sessions.set([]) +}) + +describe('$sidebarSessionRankIds', () => { + it('ranks the priciest session first', () => { + $sessions.set([ + session('cheap', { actual_cost_usd: 0.01 }), + session('dear', { actual_cost_usd: 2 }), + session('estimated', { estimated_cost_usd: 0.5 }) + ]) + setSidebarOrdering('cost') + + expect($sidebarSessionRankIds.get()).toEqual(['dear', 'estimated', 'cheap']) + }) + + it('ranks by total tokens, both halves counted', () => { + $sessions.set([ + session('small', { input_tokens: 10, output_tokens: 10 }), + session('big', { input_tokens: 1, output_tokens: 500 }) + ]) + setSidebarOrdering('tokens') + + expect($sidebarSessionRankIds.get()).toEqual(['big', 'small']) + }) + + it('ranks by creation, newest first — the sidebar orders by recency elsewhere', () => { + $sessions.set([session('older', { started_at: 1 }), session('newer', { started_at: 9 })]) + setSidebarOrdering('created') + + expect($sidebarSessionRankIds.get()).toEqual(['newer', 'older']) + }) + + it('leaves the default view unranked, and hands back the same array each time', () => { + $sessions.set([session('a', { actual_cost_usd: 1 }), session('b', { actual_cost_usd: 2 })]) + + const first = $sidebarSessionRankIds.get() + + $sessions.set([session('c', { actual_cost_usd: 3 })]) + + expect(first).toEqual([]) + // Reference-stable, so the default sidebar never repaints on a rank it isn't using. + expect($sidebarSessionRankIds.get()).toBe(first) + }) + + it('drops the ranking when a hand-dragged order takes over', () => { + $sessions.set([session('a', { actual_cost_usd: 1 }), session('b', { actual_cost_usd: 2 })]) + setSidebarOrdering('cost') + setSidebarOrdering('manual') + + expect($sidebarSessionRankIds.get()).toEqual([]) + }) +}) diff --git a/apps/desktop/src/store/sidebar-sort.ts b/apps/desktop/src/store/sidebar-sort.ts new file mode 100644 index 0000000000..9b7312603a --- /dev/null +++ b/apps/desktop/src/store/sidebar-sort.ts @@ -0,0 +1,62 @@ +import { computed, type ReadableAtom } from 'nanostores' + +import type { SessionInfo } from '@/hermes' + +import { $sidebarOrdering, type SidebarOrdering } from './layout' +import { $sessions } from './session' +import { $sessionDotStateById, type SessionDotState, sessionStatusRank } from './session-dot-state' +import { sessionCostUsd } from './sidebar-archive' + +// Same array on every recompute, so the default (unranked) sidebar never churns +// its subscribers. +const UNRANKED: string[] = [] + +function rankBy( + ordering: SidebarOrdering, + dotStates: Record +): null | ((session: SessionInfo) => number) { + switch (ordering) { + case 'cost': + return session => -sessionCostUsd(session) + + case 'created': + return session => -session.started_at + + case 'status': + return session => sessionStatusRank(dotStates[session.id]) + + case 'tokens': + return session => -(session.input_tokens + session.output_tokens) + + default: + return null + } +} + +/** + * The active sort key as a plain id order — the one ranking every sidebar + * surface reads. + * + * The sort key used to be applied where the flat list is assembled, so it did + * nothing at all once rows moved into groups: picking "cost" while grouped by + * project or profile left every lane in the order the backend sent it. Ranking + * lives here instead, above any one view, and each surface applies it to the + * rows it owns — the flat list within its date dividers, a group within its + * lane (and before it trims itself to a preview, so the rows it drops are the + * ones the sort key ranked last). + * + * Empty for `updated` and `manual`: recency is the order sessions already + * arrive in, and a hand-dragged sequence is the flat list's own business. + */ +export const $sidebarSessionRankIds: ReadableAtom = computed( + [$sidebarOrdering, $sessions, $sessionDotStateById], + (ordering, sessions, dotStates) => { + const rank = rankBy(ordering, dotStates) + + if (!rank) { + return UNRANKED + } + + return [...sessions].sort((a, b) => rank(a) - rank(b)).map(session => session.id) + } +) From 771b214516d7f89f185c8dc3b959e9f76c97aa8c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 10 Aug 2026 03:13:04 -0500 Subject: [PATCH 3/6] feat(desktop): show every profile's sessions in the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All-profiles mode listed a flat page of chats and stopped there: the project tree was the active profile's, grouping and filtering had no notion of an owner, and each profile lane paged itself against a separate endpoint. Multi-agent workflows live across profiles, so the sidebar now treats the owner as a first-class axis. Group by profile (the default in this scope, with its own persisted choice so flipping the rail doesn't reset how you read one profile), filter by profile, and start or import one from the same menu. Profile groups take the project row's shape rather than a hand-rolled header, preview the same three sessions a project does, and carry their whole tokens-and-spend total in the slot the kebab hovers over. Grouped lanes now rank by the active sort key, before they trim themselves, so the rows a group hides are the ones the sort ranked last. Defaults live in one const: the sidebar ships grouped by date, sorted by recency, with the timestamp pinned — and "Reset to defaults" puts back exactly that. --- apps/desktop/src/app/chat/close-tab.test.ts | 7 +- apps/desktop/src/app/chat/sidebar/chrome.tsx | 89 ++++++++++ .../src/app/chat/sidebar/filter-menu.tsx | 56 +++++- apps/desktop/src/app/chat/sidebar/index.tsx | 161 +++++++++--------- .../sidebar/projects/overview-row.test.tsx | 18 +- .../chat/sidebar/projects/overview-row.tsx | 65 +++---- .../chat/sidebar/projects/workspace-group.tsx | 153 ++++++++++------- .../sidebar/projects/workspace-groups.test.ts | 17 +- .../chat/sidebar/projects/workspace-groups.ts | 24 ++- .../src/app/contrib/latest-actions.test.ts | 3 - .../desktop/src/app/contrib/latest-actions.ts | 1 - apps/desktop/src/app/contrib/types.ts | 1 - apps/desktop/src/app/contrib/wiring.tsx | 2 - .../session/hooks/use-session-list-actions.ts | 42 ++--- apps/desktop/src/hermes.ts | 3 + apps/desktop/src/store/gateway-switch.ts | 2 + .../src/store/layout-sidebar-view.test.ts | 67 ++++++++ apps/desktop/src/store/layout.ts | 105 ++++++++++-- apps/desktop/src/store/projects.ts | 89 +++++++--- .../src/store/session-pin-sync.test.ts | 3 + apps/desktop/src/store/session.ts | 12 ++ 21 files changed, 646 insertions(+), 274 deletions(-) create mode 100644 apps/desktop/src/store/layout-sidebar-view.test.ts diff --git a/apps/desktop/src/app/chat/close-tab.test.ts b/apps/desktop/src/app/chat/close-tab.test.ts index 880e2edbbe..219f20df6d 100644 --- a/apps/desktop/src/app/chat/close-tab.test.ts +++ b/apps/desktop/src/app/chat/close-tab.test.ts @@ -1,3 +1,4 @@ +import { atom } from 'nanostores' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const closeFocusedSessionTab = vi.fn(() => false) @@ -17,7 +18,11 @@ vi.mock('@/store/session-states', () => ({ })) vi.mock('@/store/profile', () => ({ - requestFreshSession: () => requestFreshSession() + // The layout store reads the sidebar's profile scope; this suite only cares + // about the fresh-session call. + $showAllProfiles: atom(false), + requestFreshSession: () => requestFreshSession(), + setShowAllProfiles: () => {} })) import { $previewTabs, closeRightRail, openPreview, type PreviewTarget } from '@/store/preview' diff --git a/apps/desktop/src/app/chat/sidebar/chrome.tsx b/apps/desktop/src/app/chat/sidebar/chrome.tsx index 5adf70a20e..3d422ab3c8 100644 --- a/apps/desktop/src/app/chat/sidebar/chrome.tsx +++ b/apps/desktop/src/app/chat/sidebar/chrome.tsx @@ -1,8 +1,13 @@ +import { useStore } from '@nanostores/react' import type * as React from 'react' import { Codicon } from '@/components/ui/codicon' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { RowButton } from '@/components/ui/row-button' +import { Tip } from '@/components/ui/tooltip' +import { compactNumber } from '@/lib/format' import { cn } from '@/lib/utils' +import { $sidebarRowMeta } from '@/store/layout' // Shared, content-agnostic sidebar chrome — used by both the flat session // sections and the project/workspace tree, so it lives outside either to keep @@ -118,6 +123,90 @@ export function SidebarRowLabel({ className, ...props }: React.ComponentProps<'s return } +/** What a group's sessions add up to, for the Show options that count something. */ +export interface SidebarGroupTotals { + costUsd: number + tokens: number +} + +/** + * Header for a group of sessions that hangs its rows underneath — a project, a + * profile. Row-shaped rather than caption-shaped (that's {@link SidebarDateDivider}, + * for groupings that only separate), so a group header lines up with the session + * rows it heads. `toggle` omitted keeps the caret's space with nothing to reveal. + */ +export function SidebarGroupRow({ + actions, + className, + label, + lead, + toggle, + totals, + ...props +}: React.ComponentProps<'div'> & { + actions?: React.ReactNode + label: React.ReactNode + lead: React.ReactNode + toggle?: { ariaLabel: string; onToggle: () => void; open: boolean } + totals?: SidebarGroupTotals +}) { + const rowMeta = useStore($sidebarRowMeta) + + const facts = [ + totals && rowMeta.includes('tokens') && totals.tokens > 0 ? compactNumber(totals.tokens) : null, + // Sub-cent spend rounds to "$0.00", which reads as a bug rather than as a + // cheap group — below a cent the header says nothing at all. + totals && rowMeta.includes('cost') && totals.costUsd >= 0.01 ? `$${totals.costUsd.toFixed(2)}` : null + ].filter(Boolean) as string[] + + return ( + + + {facts.join(' · ')} + + {actions ?
{actions}
: null} + + ) : ( + actions + ) + } + className={cn('group/workspace', className)} + {...props} + > + + {lead} + {label} + {toggle ? ( + + + + ) : ( + + )} + +
+ ) +} + /** Dot ↔ grabber swap for dnd-kit reorder rows. */ export function SidebarRowGrab({ ariaLabel, diff --git a/apps/desktop/src/app/chat/sidebar/filter-menu.tsx b/apps/desktop/src/app/chat/sidebar/filter-menu.tsx index 2bb1e247c8..f7019c3a8f 100644 --- a/apps/desktop/src/app/chat/sidebar/filter-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/filter-menu.tsx @@ -26,6 +26,7 @@ import { $sidebarGrouping, $sidebarOrdering, $sidebarPrFilter, + $sidebarProfileFilter, $sidebarProjectFilter, $sidebarRowMeta, $sidebarShowArchived, @@ -41,10 +42,19 @@ import { type SidebarOrdering, type SidebarRowMeta, toggleSidebarPrFilter, + toggleSidebarProfileFilter, toggleSidebarProjectFilter, toggleSidebarRowMeta, toggleSidebarStatusFilter } from '@/store/layout' +import { + $profiles, + $showAllProfiles, + normalizeProfileKey, + requestProfileCreate, + toggleShowAllProfiles +} from '@/store/profile' +import { runImportProfileFlow } from '@/store/profile-share' import { $projectTree } from '@/store/projects' import type { PullRequestBucket } from '@/store/pull-requests' import { $unreadFinishedSessionIds, markAllSessionsRead } from '@/store/session' @@ -62,7 +72,8 @@ interface Option { const GROUPINGS: Option[] = [ { icon: 'clock', id: 'date', label: 'Updated' }, { icon: 'root-folder', id: 'project', label: 'Project' }, - { icon: 'pulse', id: 'status', label: 'Status' } + { icon: 'pulse', id: 'status', label: 'Status' }, + { icon: 'account', id: 'profile', label: 'Profile' } ] const ORDERINGS: Option[] = [ @@ -141,6 +152,10 @@ export function SidebarFilterMenu({ className }: { className?: string }) { const rowMeta = useStore($sidebarRowMeta) const statusFilter = useStore($sidebarStatusFilter) const projectFilter = useStore($sidebarProjectFilter) + const profileFilter = useStore($sidebarProfileFilter) + const showAllProfiles = useStore($showAllProfiles) + const profileNames = useStore($profiles).map(profile => normalizeProfileKey(profile.name)) + const narrowsByProfile = showAllProfiles && profileNames.length > 1 const prFilter = useStore($sidebarPrFilter) const showArchived = useStore($sidebarShowArchived) const filtersActive = useStore($sidebarFiltersActive) @@ -287,6 +302,32 @@ export function SidebarFilterMenu({ className }: { className?: string }) { )} + + Profile + + {/* Scoped to one profile the rail is already the filter, so the + per-profile boxes only appear where they can narrow something. + The actions below stand on their own. */} + {narrowsByProfile && ( + <> + {profileNames.map(name => ( + toggleSidebarProfileFilter(name)} + option={{ icon: 'account', id: name, label: name }} + /> + ))} + + + )} + {t.profiles.newProfile} + void runImportProfileFlow()}> + {t.profiles.importProfile} + + + + {projects.length > 1 && ( Project @@ -308,6 +349,19 @@ export function SidebarFilterMenu({ className }: { className?: string }) { )} + {/* Off by default: one profile's sessions are what the rail selected. + Nothing to widen to until a second profile exists — but stay + visible while it's on, or deleting your way back down to one + profile would strand the sidebar in a mode nothing can leave (the + rail hides its switcher at one profile too). */} + {(profileNames.length > 1 || showAllProfiles) && ( + + )} + setSidebarShowArchived(!showArchived)} diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index d22f84e0bc..05a0f2f811 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -26,7 +26,7 @@ import { useContributions } from '@/contrib/react/use-contributions' import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' import { useI18n } from '@/i18n' import { comboTokens } from '@/lib/keybinds/combo' -import { profileColor } from '@/lib/profile-color' +import { resolveProfileColor } from '@/lib/profile-color' import { sessionMatchesSearch } from '@/lib/session-search' import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source' import { cn } from '@/lib/utils' @@ -44,6 +44,7 @@ import { $sidebarPinsOpen, $sidebarPrDataWanted, $sidebarPrFilter, + $sidebarProfileFilter, $sidebarProjectFilter, $sidebarProjectOrderIds, $sidebarRecentsOpen, @@ -69,7 +70,14 @@ import { toggleSidebarMessagingOpen, unpinSession } from '@/store/layout' -import { $newChatProfile, $profiles, $profileScope, ALL_PROFILES, normalizeProfileKey } from '@/store/profile' +import { + $newChatProfile, + $profileColors, + $profiles, + $profileScope, + ALL_PROFILES, + normalizeProfileKey +} from '@/store/profile' import { $activeProjectId, $projects, @@ -110,9 +118,10 @@ import { sessionPinId, setCurrentCwd } from '@/store/session' -import { $sessionDotStateById, sessionStatusBucket, sessionStatusRank } from '@/store/session-dot-state' +import { $sessionDotStateById, sessionStatusBucket } from '@/store/session-dot-state' import { $focusedStoredSessionId, $workingSessionIds, type SplitDir } from '@/store/session-states' -import { $archivedSessions, loadArchivedSessions, sessionCostUsd } from '@/store/sidebar-archive' +import { $archivedSessions, loadArchivedSessions } from '@/store/sidebar-archive' +import { $sidebarSessionRankIds } from '@/store/sidebar-sort' import { type AppView, @@ -256,7 +265,6 @@ interface ChatSidebarProps extends React.ComponentProps { currentView: AppView onNavigate: (item: SidebarNavItem) => void onLoadMoreSessions: () => Promise | void - onLoadMoreProfileSessions?: (profile: string) => Promise | void onLoadMoreMessaging?: (platform: string) => Promise | void onResumeSession: (sessionId: string) => void onDeleteSession: (sessionId: string) => void @@ -273,7 +281,6 @@ export function ChatSidebar({ currentView, onNavigate, onLoadMoreSessions, - onLoadMoreProfileSessions, onLoadMoreMessaging, onResumeSession, onDeleteSession, @@ -319,6 +326,7 @@ export function ChatSidebar({ const ordering = useStore($sidebarOrdering) const statusFilter = useStore($sidebarStatusFilter) const projectFilter = useStore($sidebarProjectFilter) + const profileFilter = useStore($sidebarProfileFilter) const prFilter = useStore($sidebarPrFilter) const prDataWanted = useStore($sidebarPrDataWanted) const prBranchOverrides = useStore($prBranchBySession) @@ -327,6 +335,9 @@ export function ChatSidebar({ const showArchived = useStore($sidebarShowArchived) const archivedSessions = useStore($archivedSessions) const dotStates = useStore($sessionDotStateById) + // The active sort key as an id order. The flat list applies it within its + // dividers; groups apply it to their own lanes. + const sortOrderIds = useStore($sidebarSessionRankIds) const agentsGrouped = grouping === 'project' const pinnedSessionIds = useStore($pinnedSessionIds) const pinsOpen = useStore($sidebarPinsOpen) @@ -344,6 +355,7 @@ export function ChatSidebar({ const sessionsLoading = useStore($sessionsLoading) const sessionProfilesTruncated = useStore($sessionProfilesTruncated) const profiles = useStore($profiles) + const profileColors = useStore($profileColors) const profileScope = useStore($profileScope) // Only surface the profile switcher when more than one profile exists, so // single-profile users see the unchanged sidebar. @@ -373,7 +385,6 @@ export function ChatSidebar({ const [serverMatches, setServerMatches] = useState([]) const [searchPending, setSearchPending] = useState(false) const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false) - const [profileLoadMorePending, setProfileLoadMorePending] = useState>({}) const [messagingLoadMorePending, setMessagingLoadMorePending] = useState>({}) const [recentsLoadMorePending, setRecentsLoadMorePending] = useState(false) const messagingOpenIds = useStore($sidebarMessagingOpenIds) @@ -440,6 +451,12 @@ export function ChatSidebar({ return false } + // Narrowing to a few of the profiles on screen. Scoped to one profile the + // list is already that profile's, so a stale selection can't blank it. + if (showAllProfiles && profileFilter.length && !profileFilter.includes(normalizeProfileKey(session.profile))) { + return false + } + if (prFilter.length) { const key = sessionPrKey(session) @@ -452,10 +469,14 @@ export function ChatSidebar({ // lands in the lane the user picked it from. return !projectFilter.length || projectFilter.includes(liveSessionProjectId(session, projects) ?? '') }, - [statusFilter, projectFilter, prFilter, pullRequests, projects, dotStates] + [statusFilter, projectFilter, profileFilter, showAllProfiles, prFilter, pullRequests, projects, dotStates] ) - const filtersNarrow = statusFilter.length > 0 || projectFilter.length > 0 || prFilter.length > 0 + const filtersNarrow = + statusFilter.length > 0 || + projectFilter.length > 0 || + prFilter.length > 0 || + (showAllProfiles && profileFilter.length > 0) const visibleSessions = useMemo( () => (filtersNarrow ? scopedSessions.filter(sessionMatchesFilters) : scopedSessions), @@ -634,7 +655,7 @@ export function ChatSidebar({ // Workspace grouping is a `project -> repo -> lane -> sessions` tree computed // authoritatively on the backend (projects.tree). Parents reorder via // workspaceParentOrderIds; worktrees within a parent via workspaceOrderIds. - const worktreeGroupingActive = agentsGrouped && !showAllProfiles && !showArchived + const worktreeGroupingActive = agentsGrouped && !showArchived const gatewayReady = gatewayState === 'open' // The backend project tree is a structural snapshot, NOT a per-message feed. @@ -651,6 +672,18 @@ export function ChatSidebar({ if (worktreeGroupingActive) { void refreshProjects() + + // The all-profiles tree is served off every profile's databases at once + // and deliberately leaves discovery out — a repo with no sessions is the + // same repo in every profile, so scanning here would multiply empty lanes + // by the profile count and write the result into profiles the user isn't + // driving. + if (showAllProfiles) { + void refreshProjectTree() + + return + } + // Paint the list from the fast tree fetch (explicit projects + repos from // existing sessions / the backend cache) FIRST, then kick off the heavy // home-dir git crawl so newly-discovered repos fold in afterward — instead @@ -667,7 +700,7 @@ export function ChatSidebar({ const warm = window.setTimeout(() => void refreshProjectTree(), PROJECT_TREE_WARM_MS) return () => window.clearTimeout(warm) - }, [worktreeGroupingActive, profileScope, gatewayReady]) + }, [worktreeGroupingActive, showAllProfiles, profileScope, gatewayReady]) // Sessions the branch join can't answer for get one look at their own // transcript — a `gh pr create` in there names the PR outright. Backfills @@ -768,6 +801,12 @@ export function ChatSidebar({ void refreshProjects() void refreshProjectTree() + // Discovery stays off while browsing every profile, for the reason the + // first fetch leaves it out. + if (showAllProfiles) { + return + } + const now = Date.now() if (now - lastScanAt >= SCAN_THROTTLE_MS) { @@ -783,7 +822,7 @@ export function ChatSidebar({ window.removeEventListener('focus', onActive) document.removeEventListener('visibilitychange', onActive) } - }, [worktreeGroupingActive, gatewayReady]) + }, [worktreeGroupingActive, showAllProfiles, gatewayReady]) // Apply the persisted repo + worktree orders to a project's repo subtrees. const orderRepos = useCallback( @@ -801,10 +840,6 @@ export function ChatSidebar({ // state on top: dismissed auto-projects, persisted repo/lane order, and the // overview sort. Membership is the backend tree's — never re-derived here. const projectModel = useMemo(() => { - if (showAllProfiles) { - return [] - } - const sorted = sortProjectsForOverview( filterVisibleProjects(projectTree, dismissedAutoProjects) // A filtered-out project drops its whole lane, header included — hiding @@ -830,7 +865,6 @@ export function ChatSidebar({ // keep their sorted position rather than jumping the hand-picked list. return orderProjectsByIds(sorted, projectOrderIds) }, [ - showAllProfiles, projectTree, dismissedAutoProjects, orderRepos, @@ -1020,8 +1054,14 @@ export function ChatSidebar({ // session shows under its project instantly (and with its working arc), // matching the flat Recents list. Keyed by project id for the rows. const overviewPreviews = useMemo>( - () => overlayLivePreviews(projectOverview ?? [], agentSessions, projects, PROJECT_PREVIEW_COUNT, removedSessionIds), - [projectOverview, agentSessions, projects, removedSessionIds] + () => + overlayLivePreviews(projectOverview ?? [], agentSessions, projects, PROJECT_PREVIEW_COUNT, { + removed: removedSessionIds, + // Rank before the trim, so "3 priciest in this project" isn't "3 most + // recent, priciest first". + rankIds: sortOrderIds + }), + [projectOverview, agentSessions, projects, removedSessionIds, sortOrderIds] ) const onEnterProject = useCallback( @@ -1070,11 +1110,6 @@ export function ChatSidebar({ [] ) - const loadMoreForProfileGroup = useCallback( - (profile: string) => runKeyedLoad(profile, onLoadMoreProfileSessions, setProfileLoadMorePending), - [onLoadMoreProfileSessions, runKeyedLoad] - ) - const loadMoreForMessaging = useCallback( (platform: string) => runKeyedLoad(platform, onLoadMoreMessaging, setMessagingLoadMorePending), [onLoadMoreMessaging, runKeyedLoad] @@ -1146,10 +1181,14 @@ export function ChatSidebar({ .sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0])) }, [messagingSessions, messagingPlatformTotals, messagingTruncated, isPinnedSession]) - // ALL-profiles view: one collapsible group per profile, color on the header + // Grouping by profile: one collapsible group per profile, color on the header // (not on every row). Default profile floats to the top, the rest alpha. + // Only reachable while the sidebar is showing every profile — scoped to one, + // it would draw a single group around the whole list. + const profileGrouped = showAllProfiles && grouping === 'profile' + const profileGroups = useMemo(() => { - if (!showAllProfiles) { + if (!profileGrouped) { return undefined } @@ -1159,7 +1198,7 @@ export function ChatSidebar({ const key = normalizeProfileKey(session.profile) const group = groups.get(key) ?? { - color: profileColor(key), + color: resolveProfileColor(key, profileColors), id: key, label: key, mode: 'profile', @@ -1172,25 +1211,11 @@ export function ChatSidebar({ groups.set(key, group) } - return ( - [...groups.values()] - .map(group => ({ - ...group, - loadingMore: Boolean(profileLoadMorePending[group.id]), - onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined, - hasMore: Boolean(sessionProfilesTruncated[group.id]) - })) - // default (root) first, then the rest alphabetically. - .sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label))) + // default (root) first, then the rest alphabetically. + return [...groups.values()].sort((a, b) => + a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label) ) - }, [ - showAllProfiles, - agentSessions, - loadMoreForProfileGroup, - onLoadMoreProfileSessions, - profileLoadMorePending, - sessionProfilesTruncated - ]) + }, [profileGrouped, agentSessions, profileColors]) // The flat Sessions list always shows ALL recent sessions; Projects is a // parallel grouped view, not a filter on this one — nothing is hidden here. @@ -1268,31 +1293,7 @@ export function ChatSidebar({ // state-based keys stay bucketed, where they read correctly per day. const rankedGlobally = ordering === 'cost' || ordering === 'tokens' - // Every sort key but `updated` is expressed as an id order applied within - // whatever dividers are on — so a bucketed key ranks rows inside each day, - // and a globally-ranked one (which has no dividers left) ranks the lot. - // `updated` is the natural order the list already arrives in, so it needs no - // ids at all. - const sortOrderIds = useMemo(() => { - const rank: null | ((session: SessionInfo) => number) = - ordering === 'status' - ? session => sessionStatusRank(dotStates[session.id]) - : ordering === 'created' - ? session => -session.started_at - : ordering === 'tokens' - ? session => -(session.input_tokens + session.output_tokens) - : ordering === 'cost' - ? session => -sessionCostUsd(session) - : null - - if (!rank) { - return undefined - } - - return [...agentSessions].sort((a, b) => rank(a) - rank(b)).map(session => session.id) - }, [ordering, agentSessions, dotStates]) - - const displayAgentGroups = showAllProfiles ? profileGroups : undefined + const displayAgentGroups = profileGroups // The recents list owns its own (virtualized) scroll container only when it's a // long flat list. In that case it must keep its scroller even in short mode, so @@ -1550,11 +1551,8 @@ export function ChatSidebar({ activeSessionId={activeSidebarSessionId} collapsible={!inProject} contentClassName={cn( - 'flex min-h-0 flex-1 flex-col pb-1.75', + 'flex min-h-0 flex-1 flex-col gap-px pb-1.75', SCROLL_Y, - // Separate profile sections clearly in the ALL view; rows inside - // each group keep their own tight gap-px rhythm. - showAllProfiles ? 'gap-3' : 'gap-px', // Flatten into the single scroll when compact — unless this is the // virtualized long list, which must keep its own scroller. !recentsVirtualizes && COMPACT_FLAT @@ -1576,10 +1574,10 @@ export function ChatSidebar({ ) } footer={ - // Hide "load more" only when workspace-grouped (those groups page - // themselves). ALL-profiles now pages per-profile from each profile - // header; the global footer only applies to non-ALL views. - !showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? ( + // Hidden only when workspace-grouped — those groups page + // themselves. Profile groups don't: this one footer fetches the + // next page, which grows every profile at once. + !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? ( void onLoadMoreRecents()} @@ -1651,7 +1649,7 @@ export function ChatSidebar({ ) : null}
- {!showAllProfiles ? : null} +
) @@ -1670,7 +1668,10 @@ export function ChatSidebar({ onBranchSession={onBranchSession} onDeleteSession={onDeleteSession} onEnterProject={onEnterProject} - onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace} + // Unlike reorder below, this stays on across profiles: a folder + // is a folder, and the new session lands in the active profile + // — the same one the composer would have started it in. + onNewSessionInWorkspace={onNewSessionInWorkspace} onReorderProjects={showAllProfiles ? undefined : reorderProjects} onReorderSessions={showAllProfiles ? undefined : reorderSessions} onResumeSession={onResumeSession} diff --git a/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx b/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx index 6630346299..896643636d 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { ReactNode } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -71,11 +71,19 @@ describe('ProjectOverviewRow', () => { expect(screen.queryByRole('button', { name: 'Show Test D sessions' })).toBeNull() }) - it('drops the "new session" add button on Home, which has no folder to start in', () => { - const home = { id: '__no_project__', isNoProject: true, label: 'Home' } as unknown as SidebarProjectTree + it('offers the "new session" add button on Home, which starts one with no folder', () => { + const home = { + id: '__no_project__', + isNoProject: true, + label: 'Home', + path: null + } as unknown as SidebarProjectTree - render() + const onNewSession = vi.fn() - expect(screen.queryByRole('button', { name: 'New session in Home' })).toBeNull() + render() + fireEvent.click(screen.getByRole('button', { name: 'New session in Home' })) + + expect(onNewSession).toHaveBeenCalledWith(null) }) }) diff --git a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx index aedd2e6e09..155d477e1e 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx @@ -2,16 +2,14 @@ import type * as React from 'react' import { useRef } from 'react' import { Codicon } from '@/components/ui/codicon' -import { DisclosureCaret } from '@/components/ui/disclosure-caret' -import { Tip } from '@/components/ui/tooltip' import type { SessionInfo } from '@/hermes' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' import { SIDEBAR_LEAD_ICON_SIZE, + SidebarGroupRow, SidebarRowBody, - SidebarRowCluster, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, @@ -113,18 +111,30 @@ export function ProjectOverviewRow({ ) const shell = ( - - {/* Home has no folder to start a chat in — the sidebar's own "New - session" is that button — and no record to rename or delete. */} - {onNewSession && !project.isNoProject && ( + {/* Home is a bucket, not a record, so there's nothing to rename or + delete — but it still starts sessions: a null path is the "no + folder" chat. New session sits outermost: it's the one you reach + for. */} + {!project.isNoProject && } + {onNewSession && ( onNewSession(project.path)} /> )} - {!project.isNoProject && } } - className={cn('group/workspace', dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')} + className={cn(dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')} + label={ + onEnter?.(project.id)} + > + {project.label} + + } + lead={lead} // The label is grab surface too, not just the lead's grabber — same // listeners, minus the controls that keep their own gestures. A project // row has no rival drag (its title navigates on CLICK), so the sortable @@ -138,36 +148,13 @@ export function ProjectOverviewRow({ dragHandleProps?.onPointerDown?.(event) }} ref={rowRef} - > - - {lead} - onEnter?.(project.id)} - > - {project.label} - - {preview.length > 0 ? ( - - - - ) : ( - - )} - - + toggle={ + preview.length > 0 + ? { ariaLabel: s.projects.toggle(project.label, !open), onToggle: toggleOpen, open } + : undefined + } + totals={{ costUsd: project.totalCostUsd ?? 0, tokens: project.totalTokens ?? 0 }} + /> ) return ( diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx index 5c05869b5a..33f8382754 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx @@ -1,19 +1,24 @@ +import { useStore } from '@nanostores/react' import type * as React from 'react' import { useState } from 'react' import { Codicon } from '@/components/ui/codicon' +import { ProfileGlyph } from '@/components/ui/profile-glyph' import type { SessionInfo } from '@/hermes' import { useI18n } from '@/i18n' import { displayPath } from '@/lib/display-path' +import { useStoreSelector } from '@/lib/use-session-slice' import { setWorkspaceNodeOpen } from '@/store/layout' import { notifyError } from '@/store/notifications' -import { newSessionInProfile } from '@/store/profile' +import { newSessionInProfile, selectProfile } from '@/store/profile' import { switchBranchInRepo } from '@/store/projects' +import { $sessionProfilesUsage } from '@/store/session' +import { $sidebarSessionRankIds } from '@/store/sidebar-sort' -import { SidebarRowStack } from '../chrome' -import { SidebarLoadMoreRow } from '../load-more-row' +import { SidebarGroupRow, SidebarRowLead, SidebarRowLink, SidebarRowStack } from '../chrome' +import { rankSessions } from '../order' -import { SIDEBAR_GROUP_PAGE, useWorkspaceNodeOpen } from './model' +import { PROJECT_PREVIEW_COUNT, SIDEBAR_GROUP_PAGE, useWorkspaceNodeOpen } from './model' import type { SidebarSessionGroup } from './workspace-groups' import { WorkspaceAddButton, @@ -36,6 +41,10 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov const { t } = useI18n() const s = t.sidebar const isProfileGroup = group.mode === 'profile' + // Totals for the whole profile, not the loaded page — a selector so a refresh + // that leaves this profile's spend unchanged doesn't repaint its header. + const usage = useStoreSelector($sessionProfilesUsage, all => all[group.id]) + const rankIds = useStore($sidebarSessionRankIds) // Empty worktree/branch lanes start collapsed — they only show a "No sessions // yet" placeholder, so defaulting them open just adds noise. Profile lanes and // lanes that already hold sessions default open. @@ -43,21 +52,18 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov const [open, toggleOpen] = useWorkspaceNodeOpen(group.id, defaultOpen) const [visibleCount, setVisibleCount] = useState(SIDEBAR_GROUP_PAGE) - const loadedCount = group.sessions.length - const visibleSessions = group.sessions.slice(0, visibleCount) - // Profile groups can have more rows on the server than are loaded — the - // aggregator reports `hasMore` so the lane can offer another page without - // pricing an exact total per refresh. Workspace groups only ever page within - // what's already loaded. - const hiddenLoaded = Math.max(0, loadedCount - visibleSessions.length) - const hiddenCount = isProfileGroup && group.hasMore ? Math.max(hiddenLoaded, 1) : hiddenLoaded + // A lane ranks by whatever the sort key says before it trims itself, so the + // rows it hides are the ones the sort ranked last. + const sessions = rankSessions(group.sessions, rankIds) + // A profile previews the same handful a project does, and clicking its label + // is how you see the rest. Workspace groups page within what's loaded. + const visibleSessions = sessions.slice(0, isProfileGroup ? PROJECT_PREVIEW_COUNT : visibleCount) + const hiddenCount = isProfileGroup ? 0 : sessions.length - visibleSessions.length const nextCount = Math.min(SIDEBAR_GROUP_PAGE, hiddenCount) - // Leading glyph: profile color dot, a home mark for the repo's primary - // checkout (labeled by its live branch), or a branch/kanban mark otherwise. - const leadingIcon = group.color ? ( -