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

@ -265,6 +265,7 @@ CREATE TABLE IF NOT EXISTS sessions (
handoff_error TEXT,
rewind_count INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
icon TEXT,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);
@ -1444,6 +1445,23 @@ class SessionDB:
rowcount = self._execute_write(_do)
return rowcount > 0
def set_session_icon(self, session_id: str, icon: Optional[str]) -> bool:
"""Set or clear a session's user-chosen icon glyph.
``icon`` is a short display string (an emoji or a couple of chars);
passing None/"" clears it. Returns True when a row was updated.
"""
cleaned = (icon or "").strip()[:16] or None
def _do(conn):
cursor = conn.execute(
"UPDATE sessions SET icon = ? WHERE id = ?",
(cleaned, session_id),
)
return cursor.rowcount
rowcount = self._execute_write(_do)
return rowcount > 0
def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
"""Look up a session by exact title. Returns session dict or None."""
with self._lock:
@ -3053,26 +3071,46 @@ class SessionDB:
min_message_count: int = 0,
include_archived: bool = False,
archived_only: bool = False,
exclude_children: bool = False,
) -> int:
"""Count sessions, optionally filtered by source."""
"""Count sessions, optionally filtered by source.
Pass ``exclude_children=True`` to count only the conversations that
``list_sessions_rich`` surfaces (root + branch sessions), hiding
sub-agent runs and compression continuations. Use it whenever the count
is paired with a ``list_sessions_rich`` page (e.g. sidebar "load more"
totals) so the total matches the number of listable rows — otherwise the
raw row count is inflated by children and "load more" never settles.
"""
where_clauses = []
params = []
if exclude_children:
# Mirror list_sessions_rich's child-exclusion clause exactly so the
# count lines up with the rows: roots (no parent) plus branch
# children (parent ended with end_reason='branched').
where_clauses.append(
"(s.parent_session_id IS NULL"
" OR EXISTS (SELECT 1 FROM sessions p"
" WHERE p.id = s.parent_session_id"
" AND p.end_reason = 'branched'"
" AND s.started_at >= p.ended_at))"
)
if source:
where_clauses.append("source = ?")
where_clauses.append("s.source = ?")
params.append(source)
if min_message_count > 0:
where_clauses.append("message_count >= ?")
where_clauses.append("s.message_count >= ?")
params.append(min_message_count)
if archived_only:
where_clauses.append("archived = 1")
where_clauses.append("s.archived = 1")
elif not include_archived:
where_clauses.append("archived = 0")
where_clauses.append("s.archived = 0")
where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
with self._lock:
cursor = self._conn.execute(f"SELECT COUNT(*) FROM sessions{where_sql}", params)
cursor = self._conn.execute(f"SELECT COUNT(*) FROM sessions s{where_sql}", params)
return cursor.fetchone()[0]
def message_count(self, session_id: str = None) -> int: