feat(desktop): per-session profile switching + cross-profile sessions

Add first-class profile support to the desktop app without app reloads.

- Swap the single live gateway onto a session's profile lazily (spawned on
  demand by the Electron backend pool), so one backend serves the active
  profile and others stay cold — no OOM with many profiles.
- Aggregate sessions across profiles by reading each profile's state.db
  read-only; unified "All profiles" view groups sessions per profile with
  per-profile pagination, while the default view stays scoped to one profile.
- Add an Arc-style profile rail at the sidebar foot: a default<->all toggle
  pinned left, colored named-profile squares scrolling between, Manage pinned
  right. Profile identity is a deterministic per-name color.
- Route profile-scoped REST (config/env/skills/tools/model) to the active
  gateway profile and invalidate React Query caches on swap. Single-profile
  users never trigger a swap, so their path is unchanged.

Backend:
- web_server: profile-aware active/list endpoints + per-profile session
  totals; hermes_state: session_count(exclude_children); main.py: honor
  --profile over HERMES_HOME env for pooled backends.

UI primitives:
- Add a position-aware Tip tooltip (instant, themed) as a drop-in for native
  title=, and strip redundant tooltips from self-descriptive chrome.
This commit is contained in:
Brooklyn Nicholson
2026-06-04 16:35:34 -05:00
parent 62f0cfd902
commit b94b3622b5
52 changed files with 2517 additions and 796 deletions
+78
View File
@@ -381,6 +381,60 @@ class TestWebServerEndpoints:
resp = self.client.patch("/api/sessions/no-fields", json={})
assert resp.status_code == 400
def test_set_and_clear_session_icon_via_patch(self):
"""PATCH icon sets a per-session glyph; "" clears it back to None."""
from hermes_state import SessionDB
db = SessionDB()
try:
db.create_session(session_id="icon-me", source="cli")
finally:
db.close()
resp = self.client.patch("/api/sessions/icon-me", json={"icon": "🦊"})
assert resp.status_code == 200
assert resp.json()["icon"] == "🦊"
db = SessionDB()
try:
assert db.get_session("icon-me")["icon"] == "🦊"
finally:
db.close()
resp = self.client.patch("/api/sessions/icon-me", json={"icon": ""})
assert resp.status_code == 200
assert resp.json()["icon"] is None
db = SessionDB()
try:
assert db.get_session("icon-me")["icon"] is None
finally:
db.close()
def test_profiles_sessions_tags_default_profile(self):
"""The cross-profile aggregator returns the default profile's rows
tagged profile="default" (single-profile parity with /api/sessions)."""
from hermes_state import SessionDB
db = SessionDB()
try:
db.create_session(session_id="agg-me", source="cli")
db.append_message(session_id="agg-me", role="user", content="hi")
finally:
db.close()
resp = self.client.get("/api/profiles/sessions?limit=20&min_messages=0")
assert resp.status_code == 200
data = resp.json()
row = next(s for s in data["sessions"] if s["id"] == "agg-me")
assert row["profile"] == "default"
assert row["is_default_profile"] is True
assert isinstance(data.get("errors"), list)
def test_profiles_sessions_rejects_unknown_archived_value(self):
resp = self.client.get("/api/profiles/sessions?archived=bogus")
assert resp.status_code == 400
def test_get_sessions_rejects_unknown_archived_value(self):
resp = self.client.get("/api/sessions?archived=bogus")
assert resp.status_code == 400
@@ -1603,6 +1657,30 @@ class TestNewEndpoints:
profiles = {p["name"]: p for p in self.client.get("/api/profiles").json()["profiles"]}
assert profiles["cloned"]["skill_count"] == 1
def test_profiles_create_with_clone_from_duplicates_source(self, monkeypatch):
from hermes_constants import get_hermes_home
import hermes_cli.profiles as profiles_mod
monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None)
# Create a source profile and give it a distinctive skill.
assert self.client.post("/api/profiles", json={"name": "source-prof"}).status_code == 200
source_skill = get_hermes_home() / "profiles" / "source-prof" / "skills" / "custom" / "src-skill"
source_skill.mkdir(parents=True)
(source_skill / "SKILL.md").write_text("---\nname: src-skill\n---\n", encoding="utf-8")
# Duplicate it via an explicit clone_from source (not "default").
resp = self.client.post(
"/api/profiles",
json={"name": "source-prof-copy", "clone_from": "source-prof"},
)
assert resp.status_code == 200
cloned_skill = (
get_hermes_home() / "profiles" / "source-prof-copy" / "skills" / "custom" / "src-skill" / "SKILL.md"
)
assert cloned_skill.exists()
def test_profiles_create_without_clone_seeds_bundled_skills(self, monkeypatch):
from hermes_constants import get_hermes_home
import hermes_cli.profiles as profiles_mod