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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user