feat(desktop): concurrent multi-profile sessions, cross-profile @session links#39330
Merged
Conversation
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.
…ebar - Add a "+" in the profile rail that opens a self-contained CreateProfileDialog (name + clone toggle + optional SOUL.md); extract it and ActionStatus from the profiles view so both surfaces share one flow. - Keep the profile rail pinned to the bottom when a profile has no sessions by rendering a flex-1 spacer (previously the rail floated up to the nav).
Make the named-profile squares reorderable via dnd-kit (horizontal sort, 4px activation so a tap still selects). Order persists in localStorage ($profileOrder); unordered/new profiles alphabetize at the tail.
Bind Cmd/Ctrl+P to the command palette alongside Cmd+K (VS Code quick-open muscle memory); Cmd+. stays the command center. No Print accelerator competes, so the renderer preventDefault is enough.
overflow-x-auto makes overflow-y compute to auto, so a vertical drag translate faulted in a cross-axis scrollbar. Pin the drag transform to y:0 with a modifier — squares only slide horizontally now.
Snap the drag transform to whole cells (no free glide) and clamp it to the occupied squares strip via a relative wrapper as offsetParent, so a square can't float past the last profile onto the "+" and break the layout.
- Wheel maps vertical scroll → horizontal so the rail is navigable with a plain mouse (trackpad x-scroll still passes through). - Springy easeOutBack reflow; dragged square glides between snapped cells (no scale — overflow-x strip would clip it) with a subtle lift. - Haptic 'selection' tick per crossed cell + 'success' on a committed reorder.
If a user drops back to a single profile while scope is still ALL (persisted), the rail is hidden — they'd be stuck in the grouped view with no toggle out. Fall back to the scoped view when only one profile.
Always mount the profile rail, but when only the default profile exists render just the create-profile "+" (hide the default/all toggle, the draggable squares, and Manage). Gives a first-profile affordance without the full switcher chrome; everything else appears once a 2nd profile exists.
Left-align the default's home icon next to the create "+" in the single-profile state (toggle/squares/Manage still appear only once a second profile exists).
The per-session icon picker added more noise than value — rip it out end to end (sessions.icon column, set_session_icon, the PATCH field, the picker UI, and the SessionInfo.icon type). The cross-profile session aggregator now opens each profile's state.db read-only (mode=ro, no schema init), so listing other profiles on every sidebar refresh never DDLs or takes a write lock on their live DBs. The single-profile hot path stays on par with /api/sessions.
- right-click a profile square to rename or delete it, via shared self-contained dialogs (also reused by the profiles page) - switching or creating a profile now resets to a fresh new-session draft so the prior session doesn't stay sticky across contexts - deleting the profile you're currently in falls back to default instead of stranding the gateway on a dead profile - shared ConfirmDialog: Enter/Space confirm from anywhere in the dialog; profile-delete and cron-delete both route through it
Drag a sidebar session into the composer to drop an @session:<profile>/<id> chip the agent resolves via session_search. New READ shape dumps a whole session by id (head+tail when large); a `profile` param reads another profile's DB read-only, and a cross-profile locate scan resolves bare ids when the model drops the owning profile from the link. Also: ASCII "waking up <profile>" overlay during lazy gateway swaps, global haptic rate-limit to kill the reconnect-storm "clickity" buzz, and reauth toasts surfaced once per disconnect instead of every backoff tick.
Centralize the fallback in DeleteProfileDialog (the single delete choke point) so both the rail and the Profiles view inherit it. Reset *after* the host's onDeleted refresh so a refreshActiveProfile racing the dying backend can't clobber the pill back to the deleted profile, and set $activeProfile too (selectProfile only moved the gateway, leaving the statusbar pill stranded on the dead profile).
Hold (~450ms) a profile square — or right-click → Color… — to open a shadcn Popover of swatches and override its rail color, with Auto to fall back to the deterministic hue. The hold timer rides alongside the dnd pointer listener (a real drag cancels it, the trailing click is suppressed), so reorder/select/recolor stay distinct gestures. Overrides persist in localStorage ($profileColors), resolved via resolveProfileColor (override wins, else the name-hashed hue). Cosmetic and gated on the multi-profile rail, so single-profile users are unaffected. Adds a reusable ui/popover.tsx (radix-ui umbrella).
Resolve conflicts in desktop settings/cron/messaging/sidebar: adopt main's ListRow + actions-menu refactors for credential rows; keep our profileColor import on the sidebar. Drop the now-orphaned Tip-based helpers.
Keep one persistent socket per profile with live work instead of closing the single socket on every profile swap, so background sessions across profiles keep streaming at once. A gateway registry owns the primary (window) socket plus lazy secondaries (own backoff/reconnect); all feed the same session-keyed event handler. Secondaries are pruned to profiles with a working/needs-input session, the keepalive pings every open backend, and LRU eviction spares freshly-touched backends so the soft cap can't abort a running agent. Approval/sudo/secret prompts are parked per-session (surfaced via the needs-input badge) so a background turn can block without hijacking the foreground. Single-profile users only ever have the primary, so their path is unchanged.
… view Mirror the workspace-group "+": each profile header in the all-profiles session list gets a new-session button. Unlike selecting the profile, it leaves the browse scope untouched (newSessionInProfile keeps $showAllProfiles), so creating a chat doesn't collapse the unified view.
Contributor
🔎 Lint report:
|
| Rule | Count |
|---|---|
unresolved-attribute |
2 |
invalid-argument-type |
1 |
invalid-assignment |
1 |
First entries
tests/tools/test_session_search.py:519: [invalid-argument-type] invalid-argument-type: Argument to function `session_search` is incorrect: Expected `int`, found `str`
tests/tools/test_session_search.py:459: [unresolved-attribute] unresolved-attribute: Attribute `execute` is not defined on `None` in union `Connection | None`
tools/session_search_tool.py:551: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to `str`
tests/tools/test_session_search.py:509: [unresolved-attribute] unresolved-attribute: Attribute `commit` is not defined on `None` in union `Connection | None`
✅ Fixed issues: none
Unchanged: 5103 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
davidgut1982
pushed a commit
to davidgut1982/hermes-agent
that referenced
this pull request
Jun 5, 2026
…ofile-support feat(desktop): concurrent multi-profile sessions, cross-profile @session links
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Screen.Recording.2026-06-04.at.16.36.47.mov
First-class profile support in the desktop app, with mid-thread profile switching and no app reloads. Profiles with live work hold concurrent gateway sockets so sessions across profiles keep streaming at once; backends are spawned lazily and bounded (max 3 pooled + LRU evict + idle reap), so many profiles never OOM. Single-profile users are completely unaffected.
state.dbread-only (mode=ro, no schema init) to list sessions with zero extra spawns and no write-lock contention on other profiles' live DBs. An opt-in All profiles view groups sessions per profile (collapsible headers, per-profile pagination); the default view stays scoped to the active profile. Each profile header gets a "+" that starts a fresh session in that profile without leaving the all-profiles view.@session:<profile>/<id>chip the agent resolves viasession_search(carries the source profile, so linking works across profiles). The chat drop overlay is now generic — its copy/icon adapt to whatever's being dragged (files vs. session).Backend
session_search: new READ shape (session_idwith no anchor → dumps the session, head+tail when large) and aprofileparam to read another profile's DB read-only. Resolves@session:<profile>/<id>links robustly — normalizes the prefix in every arg permutation, and if a bare id misses the target profile it scans all profiles (read-only) and reads it wherever it lives. The pre-existing browse path (baresession_id, no query/anchor) silently ignored the id, so this is strictly additive.web_server: profile-aware active/list endpoints + per-profile session totals; the cross-profile aggregator opens each profile read-only. Read paths (detail/messages/rename) take an optionalprofilethat defaults to the current process's DB.hermes_state:SessionDB(read_only=True)(no-DDLmode=roattach, no write lock — safe to poll a live profile's DB);session_count(exclude_children=…)so list totals match the rowslist_sessions_richsurfaces (fixes "load more" never settling). Both new params default off, so existing callers are unchanged.main.py: honor--profileoverHERMES_HOMEenv for pooled backends; accept comma-separated child PIDs (lone int still parses).UI primitives & polish
Tiptooltip (instant, themed) as a drop-in for nativetitle=; redundant tooltips stripped from self-descriptive chrome (statusbar, titlebar, file-tree).Popover(radix-ui umbrella, styled to match the dropdown surface) backing the rail color picker.ConfirmDialog(Enter/Space confirm from anywhere in the dialog; profile-delete and cron-delete route through it),ActionStatus(spinner→check→idle), andCreateProfileDialog/RenameProfileDialog/DeleteProfileDialogso the rail and Profiles view drive one set of flows.Test plan
npm run type-check+npm run lint(apps/desktop) cleanscripts/run_tests.sh tests/hermes_cli/test_web_server.py tests/tools/test_session_search.py tests/test_hermes_state.py— green (537 tests)pane-shell,model-settings) are pre-existing on baselinesession_searchresolves all link permutations against live data (bare id /profile/id/ explicitprofile=/ bare id +profile=), and a genuinely-absent id still errorsnpm run dev): single-profile holds at 1 backend; cross-profile browse = zero spawn; opening-into a profile spawns exactly one; pool capped at 4 total (1 primary + 3 pooled) with observed LRU evict + idle reap