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

View File

@ -7820,10 +7820,19 @@ def _kill_stale_dashboard_processes(
exclude: set[int] | None = None
raw_pid = os.environ.get("HERMES_DESKTOP_CHILD_PID")
if raw_pid:
try:
exclude = {int(raw_pid)}
except (ValueError, TypeError):
pass
# The desktop may manage several backends (one per active profile) and
# passes them comma-separated; a lone int still parses for back-compat.
parsed: set[int] = set()
for part in raw_pid.split(","):
part = part.strip()
if not part:
continue
try:
parsed.add(int(part))
except (ValueError, TypeError):
pass
if parsed:
exclude = parsed
pids = _find_stale_dashboard_pids(exclude_pids=exclude)
if not pids:

View File

@ -1592,6 +1592,7 @@ async def get_sessions(
min_message_count=min_message_count,
include_archived=include_archived,
archived_only=archived_only,
exclude_children=True,
)
now = time.time()
for s in sessions:
@ -1609,6 +1610,111 @@ async def get_sessions(
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/api/profiles/sessions")
async def get_profiles_sessions(
limit: int = 20,
offset: int = 0,
min_messages: int = 0,
archived: str = "exclude",
order: str = "recent",
profile: str = "all",
):
"""Unified, read-only session list aggregated across ALL profiles.
Intentionally process-light: this opens each profile's ``state.db`` directly
from disk — it does NOT spawn a dashboard backend per profile. Each returned
session is tagged with its owning ``profile`` so the desktop renders one
browsable list and only spins up a profile's backend when the user actually
interacts (sends a message). A user with a single (default) profile gets the
same rows as ``/api/sessions``, just tagged ``profile="default"``.
"""
if archived not in ("exclude", "only", "include"):
raise HTTPException(status_code=400, detail="archived must be one of: exclude, only, include")
if order not in ("created", "recent"):
raise HTTPException(status_code=400, detail="order must be one of: created, recent")
from hermes_state import SessionDB
from hermes_cli import profiles as profiles_mod
targets: List[Tuple[str, Path]] = []
if profile and profile != "all":
name, home = _cron_profile_home(profile)
targets.append((name, home))
else:
try:
infos = profiles_mod.list_profiles()
targets = [(info.name, info.path) for info in infos]
except Exception:
_log.exception("GET /api/profiles/sessions: list_profiles failed")
targets = []
if not targets:
targets.append(("default", profiles_mod.get_profile_dir("default")))
min_message_count = max(0, min_messages)
archived_only = archived == "only"
include_archived = archived == "include"
# Over-fetch per profile so the merged+sorted window is correct for the
# requested page. Capped so a huge profile can't blow up the response.
per_profile = min(max(limit + offset, limit), 500)
merged: List[Dict[str, Any]] = []
total = 0
profile_totals: Dict[str, int] = {}
errors: List[Dict[str, str]] = []
now = time.time()
for name, home in targets:
db_path = Path(home) / "state.db"
if not db_path.exists():
continue
try:
db = SessionDB(db_path=db_path)
except Exception as exc:
errors.append({"profile": name, "error": str(exc)})
continue
try:
rows = db.list_sessions_rich(
limit=per_profile,
offset=0,
min_message_count=min_message_count,
include_archived=include_archived,
archived_only=archived_only,
order_by_last_active=order == "recent",
)
profile_total = db.session_count(
min_message_count=min_message_count,
include_archived=include_archived,
archived_only=archived_only,
exclude_children=True,
)
total += profile_total
profile_totals[name] = profile_total
for s in rows:
s["profile"] = name
s["is_default_profile"] = name == "default"
s["is_active"] = (
s.get("ended_at") is None
and (now - s.get("last_active", s.get("started_at", 0))) < 300
)
s["archived"] = bool(s.get("archived"))
merged.append(s)
except Exception as exc:
errors.append({"profile": name, "error": str(exc)})
finally:
db.close()
sort_key = "last_active" if order == "recent" else "started_at"
merged.sort(key=lambda s: s.get(sort_key) or s.get("started_at") or 0, reverse=True)
window = merged[offset:offset + limit]
return {
"sessions": window,
"total": total,
"profile_totals": profile_totals,
"limit": limit,
"offset": offset,
"errors": errors,
}
@app.get("/api/sessions/search")
async def search_sessions(q: str = "", limit: int = 20):
"""Full-text search across session message content using FTS5.
@ -4663,15 +4769,31 @@ async def get_session_stats():
db.close()
@app.get("/api/sessions/{session_id}")
async def get_session_detail(session_id: str):
def _open_session_db_for_profile(profile: Optional[str]):
"""Open a SessionDB for read paths, optionally for another profile.
``profile`` None/empty → this process's own ``state.db`` (the common,
single-profile case). A named profile opens that profile's on-disk
``state.db`` directly so the primary backend can serve cross-profile reads
(transcripts, detail) without spawning that profile's backend.
"""
from hermes_state import SessionDB
db = SessionDB()
if not profile:
return SessionDB()
_name, home = _cron_profile_home(profile)
return SessionDB(db_path=Path(home) / "state.db")
@app.get("/api/sessions/{session_id}")
async def get_session_detail(session_id: str, profile: Optional[str] = None):
db = _open_session_db_for_profile(profile)
try:
sid = db.resolve_session_id(session_id)
session = db.get_session(sid) if sid else None
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if profile:
session["profile"] = _cron_profile_home(profile)[0]
return session
finally:
db.close()
@ -4691,9 +4813,8 @@ async def get_session_latest_descendant(session_id: str):
}
@app.get("/api/sessions/{session_id}/messages")
async def get_session_messages(session_id: str):
from hermes_state import SessionDB
db = SessionDB()
async def get_session_messages(session_id: str, profile: Optional[str] = None):
db = _open_session_db_for_profile(profile)
try:
sid = db.resolve_session_id(session_id)
if not sid:
@ -4719,25 +4840,31 @@ async def delete_session_endpoint(session_id: str):
class SessionRename(BaseModel):
title: Optional[str] = None
archived: Optional[bool] = None
# Tri-state via the client contract: omit to leave unchanged, "" to clear,
# a short glyph to set. (None == omitted here.)
icon: Optional[str] = None
# Mutate a session belonging to another profile (opens its state.db). Omit
# for the current/default profile.
profile: Optional[str] = None
@app.patch("/api/sessions/{session_id}")
async def rename_session_endpoint(session_id: str, body: SessionRename):
"""Update a session: rename (or clear its title) and/or archive it.
"""Update a session: rename (or clear its title), archive, and/or set icon.
``title`` renames (empty/null clears the title); ``archived`` soft-hides or
restores the session. Either field may be omitted.
restores the session; ``icon`` sets a per-session glyph ("" clears it). Any
field may be omitted. ``profile`` targets another profile's session.
"""
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(body.profile)
try:
sid = db.resolve_session_id(session_id)
if not sid:
raise HTTPException(status_code=404, detail="Session not found")
if body.title is None and body.archived is None:
if body.title is None and body.archived is None and body.icon is None:
raise HTTPException(
status_code=400,
detail="Nothing to update; provide 'title' and/or 'archived'.",
detail="Nothing to update; provide 'title', 'archived', and/or 'icon'.",
)
if body.title is not None:
try:
@ -4747,9 +4874,13 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
raise HTTPException(status_code=400, detail=str(e))
if body.archived is not None:
db.set_session_archived(sid, body.archived)
if body.icon is not None:
db.set_session_icon(sid, body.icon)
result = {"ok": True, "title": db.get_session_title(sid) or ""}
if body.archived is not None:
result["archived"] = bool(body.archived)
if body.icon is not None:
result["icon"] = (body.icon or "").strip()[:16] or None
return result
finally:
db.close()
@ -6156,6 +6287,11 @@ class ProfileCreate(BaseModel):
clone_all: bool = False
no_skills: bool = False
description: Optional[str] = None
# Explicit source profile to clone from (e.g. duplicating an existing
# profile). When set, it takes precedence over ``clone_from_default``,
# which always sources from "default". ``clone_all`` still selects a full
# state copytree vs. a config/skills/SOUL copy.
clone_from: Optional[str] = None
provider: Optional[str] = None
model: Optional[str] = None
@ -6316,13 +6452,23 @@ async def list_profiles_endpoint():
@app.post("/api/profiles")
async def create_profile_endpoint(body: ProfileCreate):
from hermes_cli import profiles as profiles_mod
clone = body.clone_from_default or body.clone_all
explicit_source = (body.clone_from or "").strip()
if explicit_source:
# Duplicating a specific profile: clone its config/skills/SOUL (or full
# state when clone_all) from the named source rather than "default".
clone = True
clone_from = explicit_source
clone_config = not body.clone_all
else:
clone = body.clone_from_default or body.clone_all
clone_from = "default" if clone else None
clone_config = body.clone_from_default and not body.clone_all
try:
path = profiles_mod.create_profile(
name=body.name,
clone_from="default" if clone else None,
clone_from=clone_from,
clone_all=body.clone_all,
clone_config=body.clone_from_default and not body.clone_all,
clone_config=clone_config,
no_skills=body.no_skills,
description=body.description,
)