Skip to content

feat(desktop): concurrent multi-profile sessions, cross-profile @session links#39330

Merged
OutThisLife merged 19 commits into
mainfrom
bb/desktop-profile-support
Jun 5, 2026
Merged

feat(desktop): concurrent multi-profile sessions, cross-profile @session links#39330
OutThisLife merged 19 commits into
mainfrom
bb/desktop-profile-support

Conversation

@OutThisLife

@OutThisLife OutThisLife commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator
Screenshot 2026-06-04 at 16 53 49
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.

  • Concurrent multi-profile sockets — a gateway registry keeps the primary (window) socket plus one lazy secondary socket per other profile with live work, each with its own backoff/reconnect, all feeding the same session-keyed event handler — so background sessions across profiles keep painting instead of freezing on swap. Switching profiles just re-points the active pointer; the target's socket is opened on demand and the one you came from is never closed. 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. An ASCII "waking up <profile>" overlay covers lazy-spawn latency. Single-profile users only ever hold the primary, so their path is byte-for-byte unchanged.
  • Background prompts don't stall — approval/sudo/secret prompts are parked per-session (like clarify) and surfaced via the sidebar needs-input badge, so a background turn can block on a dangerous-command/sudo/secret prompt and wait for you to switch to it instead of silently stalling. A background prompt never hijacks the foreground — overlays render only the active session's prompt.
  • Cross-profile session list — the primary backend reads every profile's state.db read-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.
  • Drag-to-link sessions — drag a sidebar session into the composer to drop an @session:<profile>/<id> chip the agent resolves via session_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).
  • Profile rail (Arc-style, sidebar foot) — a default↔all toggle pinned left, color-coded named-profile squares scrolling between, Manage pinned right. Squares are drag-sortable (order persisted; 4px activation so a tap still selects), right-click to rename/delete, and long-press (or right-click → Color…) to recolor via a swatch popover (Auto resets to the name-hashed hue; override persisted locally). A quick-create "+" opens a self-contained dialog (name + clone toggle + optional SOUL.md) shared with the Profiles view.
  • Context-switch UX — switching or creating a profile 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 (gateway + sidebar + statusbar pill all reconcile).
  • Scoped settings routing — profile-scoped REST (config/env/skills/tools/model) follows the active gateway profile; React Query caches invalidate on swap.

Backend

  • session_search: new READ shape (session_id with no anchor → dumps the session, head+tail when large) and a profile param 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 (bare session_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 optional profile that defaults to the current process's DB.
  • hermes_state: SessionDB(read_only=True) (no-DDL mode=ro attach, no write lock — safe to poll a live profile's DB); session_count(exclude_children=…) so list totals match the rows list_sessions_rich surfaces (fixes "load more" never settling). Both new params default off, so existing callers are unchanged.
  • main.py: honor --profile over HERMES_HOME env for pooled backends; accept comma-separated child PIDs (lone int still parses).

UI primitives & polish

  • Position-aware Tip tooltip (instant, themed) as a drop-in for native title=; redundant tooltips stripped from self-descriptive chrome (statusbar, titlebar, file-tree).
  • Reusable Popover (radix-ui umbrella, styled to match the dropdown surface) backing the rail color picker.
  • Shared ConfirmDialog (Enter/Space confirm from anywhere in the dialog; profile-delete and cron-delete route through it), ActionStatus (spinner→check→idle), and CreateProfileDialog / RenameProfileDialog / DeleteProfileDialog so the rail and Profiles view drive one set of flows.
  • Haptics hardened — global rolling rate-limit (≤5/s) so a reconnect/auth-expiry toast storm can't machine-gun the trackpad actuator (the "clickity" buzz); gateway reauth surfaces once per disconnect instead of on every backoff tick.

Test plan

  • npm run type-check + npm run lint (apps/desktop) clean
  • scripts/run_tests.sh tests/hermes_cli/test_web_server.py tests/tools/test_session_search.py tests/test_hermes_state.py — green (537 tests)
  • Desktop vitest green for the touched store/gateway/prompt surface; the two unrelated failures (pane-shell, model-settings) are pre-existing on baseline
  • session_search resolves all link permutations against live data (bare id / profile/id / explicit profile= / bare id + profile=), and a genuinely-absent id still errors
  • Live E2E (npm 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
  • Drag a cross-profile session into the chat → resolves to a full read with metadata
  • Manual: run two profiles concurrently (background session keeps streaming while you're in another); background approval/sudo/secret raises a needs-input badge and resolves on switch; switch profiles mid-thread; switch/create resets to a new chat; delete active → default; per-profile "+" in all-profiles view; right-click rename/delete; long-press recolor + Auto reset; single-profile user unaffected

Follow-up (not in this PR): persist rail colors to profile metadata on disk so they survive a full app-data wipe and travel across machines/surfaces. Currently localStorage (survives reopen/update/reinstall; lost only on deliberate data wipe).

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.
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/cli CLI entry point, hermes_cli/, setup wizard labels Jun 4, 2026
…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.
@OutThisLife OutThisLife changed the title feat(desktop): per-session profile switching + cross-profile sessions feat(desktop): per-session profile switching, cross-profile sessions + @session links Jun 5, 2026
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.
@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: bb/desktop-profile-support vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 9850 on HEAD, 9841 on base (🆕 +9)

🆕 New issues (4):

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.

@OutThisLife OutThisLife changed the title feat(desktop): per-session profile switching, cross-profile sessions + @session links feat(desktop): concurrent multi-profile sessions, cross-profile @session links Jun 5, 2026

@ethernet8023 ethernet8023 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we ball

@OutThisLife OutThisLife merged commit ff5652d into main Jun 5, 2026
22 checks passed
@OutThisLife OutThisLife deleted the bb/desktop-profile-support branch June 5, 2026 01:50
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants