Skip to content

feat(desktop): choose which source a new session runs on - #94457

Closed
valvesss wants to merge 10 commits into
NousResearch:mainfrom
valvesss:feat/new-session-source-picker
Closed

valvesss wants to merge 10 commits into
NousResearch:mainfrom
valvesss:feat/new-session-source-picker

Conversation

@valvesss

@valvesss valvesss commented Aug 25, 2026 •

Copy link
Copy Markdown

What

Per-session gateway source in the Hermes desktop, delivered in two layers:

Layer 1 — choose where a new session runs (shipped previously)

  • New-session source picker: with more than one gateway registered (This device + a remote/SSH gateway such as mimir), both nav New session and the header + open a dropdown to pick the connection the session runs on. Single-gateway installs see zero change.
  • Bug fix: choosing a foreign gateway no longer dials selectConnection (which wiped the list, jumped profiles, and created the session twice). It now dials only the source via ensureGatewayAgent and opens there — no flicker, no profile jump.
  • Native-theme picker + a per-row origin chip (connection kind icon + label) on foreign-gateway sessions in the session lists.

Layer 2 — same local profile, gateway per session (new in this PR)

  • Cross-gateway session aggregation: every registered connection other than the active one is fetched via a connectionId-scoped REST call (listGatewayRecentSessions) into a keyed store ($foreignGatewaySessions), rendered as an "other gateways" sidebar section under the active profile, each row badged with its origin.
  • Per-session routing: opening a foreign session from that section requestSessionResumes it with an ownerRoute {connectionId, profile} so the session-scoped RPCs dispatch on that gateway's own socket via requestForSessionProfile → requestGatewayForAgent — resuming on the owning gateway while the local profile scope stays put.

Additive by design: $sessions (the active connection's own list) is never clobbered; aggregation is opt-in per registered connection and pruned when a gateway is deregistered.

Validation

  • Unit: listGatewayRecentSessions pins connectionId+profile on a min_messages=1 recents path; $foreignGatewaySessions keyed per connectionId without touching $sessions; refreshForeignGatewaySessions fetches each foreign gateway and stores per-connection (and stays stale when a fetch errors).
  • Suite: 7478 passed | 3 skipped (719 files); typecheck green across all 3 TS projects; ESLint clean.
  • E2E: new-session source picker still green (Playwright, seeded two-source registry).

Commits

  • picker + + button (f130db8843)
  • new-session-on-source fix, no re-home (cc5f127cf8)
  • native-theme picker (c010e461d4)
  • origin chip per row (e6f8321bcf)
  • cross-gateway aggregation + per-session routing (13bddd672c)

Per-session source picker for the sidebar 'new session' action. With more
than one connection registered (Local / Remote gateway / SSH / Hermes
Cloud), clicking new session opens a dropdown to pick where that session
runs, instead of always creating it on the active agent. Single-source
installs keep the one-click behavior unchanged.

Reuses selectConnection() + startFreshSessionDraft(); no storage/core
changes. Session->source affinity persistence is a documented follow-up.

Co-authored-by: valvesss <valvesss@users.noreply.github.com>
@alt-glitch alt-glitch added type/feature New feature or request comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have labels Aug 25, 2026
…eader '+' button; add e2e

The header '+' new-session button (shown when showAllProfiles is false) called
onNewSessionInWorkspace(null) directly, bypassing the source picker — so with
multiple registered connections clicking it created a session on the active
source instead of showing the picker. Wire it through the same
NewSessionSourcePicker, gated on hasMultipleConnections.

Add an e2e spec that seeds a two-source v2 registry before launch and asserts
the sidebar 'New session' row opens the picker showing both sources.
@Enough1122

Copy link
Copy Markdown

AI code review — automated review for reference, author can ignore or act on any point.

Nice UX scoping — gating the picker behind multiple registered sources keeps single-source installs byte-for-byte identical, and the contrib plumbing (types.ts, wiring.tsx, latest-actions.ts) follows the existing pattern. Three issues worth fixing:

  1. Unhandled rejection in wiring — apps/desktop/src/app/contrib/wiring.tsx:1051: connectionId => void startSessionOnSource(connectionId, startFreshSessionDraft) discards a promise whose own contract ("Throws if the target source cannot become active", apps/desktop/src/store/connections.ts:248-253) says it can reject. A failed switch becomes an unhandled promise rejection with zero user feedback. Wrap with .catch(err => notify({ title: ..., kind: 'error' })) or equivalent so the failure surfaces in-app.

  2. Draft can start on the wrong source after a failed switch — apps/desktop/src/store/connections.ts:264-266: await selectConnection(connectionId); startFreshSessionDraft() runs the draft unconditionally. Verify whether selectConnection can swallow its own errors (its existing body suggests best-effort paths); if so, a failed re-home still opens a fresh draft on the original source — the opposite of what the user picked. Make success/failure explicit (throw, or return a boolean and bail before drafting) so "leaving the current session untouched" in the docstring is actually true end-to-end.

  3. Empty-registry edge is claimed but untested — apps/desktop/src/app/chat/sidebar/new-session-source-picker.test.tsx:47: the first test's name says "...and surface nothing when empty", but there is no empty-connections case. With hasMultipleConnections true and the registry briefly empty (connectionsRegistry?.connections ?? [] at index.tsx:1558-1562), the dropdown renders a bare label + separator. Either add the promised test asserting no items render, or render an explicit empty state.

Minor: the four-branch ternary for kindLabel (new-session-source-picker.tsx:57-63) duplicates KIND_ICON's shape — a parallel KIND_LABEL record keyed by kind would keep icon/label mappings in lockstep when a new kind is added. Otherwise the component is clean, well-typed, and the tests cover selection + display properly.

… re-home

selectConnection was doing a hard re-home (session-list wipe, profile jump,
double session create, refreshActiveProfile) — the 'flicker + profile switch'
the user hit. startSessionOnSource now dials the chosen source via
ensureGatewayAgent and opens a fresh draft, keeping the current profile and
its list in place.
Active connection highlighted with --ui-control-active-background, icon + check
in --ui-accent, native label styling. onSelect untouched.
Surfacing the owning CONNECTION on each session row (icon + label) when the
active source is a remote/SSH/cloud gateway, so a foreign connection's session
list is clearly labelled as such instead of silently appearing inside the
local profile's view. Local 'This device' stays unlabelled (the normal case).
Mirrors the cross-profile ProfileTag pattern (NousResearch#66003) on the connection axis,
threaded through the section + virtual list to the row.
Each registered connection other than the active one is fetched via a
connectionId-scoped REST call (listGatewayRecentSessions) into a keyed
store (), rendered as an 'other gateways' sidebar
section badged with its origin, and resumed routed to its owning gateway
(requestSessionResume with ownerRoute). Same local profile, gateway per
session. Additive: never touches $sessions/the active list.
Address the automated review on the PR:
- wiring: wrap startSessionOnSource in .catch() so a failed gateway
  switch surfaces an in-app error notification instead of an unhandled
  promise rejection (point 1).
- startSessionOnSource: add a store test asserting a dial that fails to
  bring the target active rejects AND does NOT open a draft on the
  original source (point 2 - behavior already guarded, now locked).
- new-session-source-picker: render an explicit empty state when no
  gateways are registered, instead of a bare label + separator (point 3),
  with a test.
- new-session-source-picker: KIND_LABEL record replaces the 4-branch
  kindLabel ternary (minor), keeping icon/label mappings in lockstep.
- i18n: add settings.connections.emptySources + startFailed keys.
@valvesss

Copy link
Copy Markdown
Author

Thanks for the review — all points addressed in 90225c16db:

  1. Unhandled rejection in wiring — onStartSessionOnSource now wraps startSessionOnSource(...) in .catch(...) and surfaces an in-app error notification (t.settings.connections.startFailed, with the underlying message appended when present) instead of an unhandled rejection.
  2. Draft on the wrong source — the current path already uses ensureGatewayAgent + an explicit guard (startSessionOnSource throws Connection "... did not become active before calling the draft), so a failed switch never opens a draft on the original source. I added store/connections.test.ts asserting the dial "succeeds" (resolves) but the target never becomes active → rejects AND openDraft is not called.
  3. Empty-registry edge — wired an explicit empty state: with zero registered connections the picker now renders a disabled "No gateways available" item (no more bare label + separator), with a test (new-session-source-picker.test.tsx) asserting no connection rows and the empty item present.
    Minor — KIND_LABEL: Record<kind, string> replaces the 4-branch kindLabel ternary, keeping icon/label mappings in lockstep.

Tests: 7480 passed | 3 skipped, typecheck (3 projects) + ESLint green.

@lorencato23

Copy link
Copy Markdown

Flagging a bug that lives right next to this PR's "Layer 2 — same local profile, gateway per session": #94811.

Short version: when two registered connections expose the same profile name (This device + a remote, both on default) and the remote is primary, ordinary session-scoped RPCs land on the primary connection's socket. A new local chat accepts its first prompt and then answers 4001 session not found on every one after it. Traced it with per-RPC logging in tui_gateway/ws.py — the runtime is alive and known=True on the local backend 256 ms after the turn ends, and the second prompt.submit simply never arrives there.

The reason it's relevant here: the root is that the primary connection has no connection id in the renderer's gateway store. setPrimaryGateway(gateway, profile) records only a profile name, so three sites treat "profile name equals the primary's profile name" as "is the primary connection":

  • gatewayForProfile — if (key === g.primaryProfile) return g.primaryGateway
  • ensureGatewayForProfile — same fast path on activation
  • activeGatewayConnectionId — returns null when activeKey === primaryProfile

That last one's own doc comment already names the exact hazard, for events:

"two connected gateways can both expose a 'default' profile, and a bare profile comparison attributed gateway B's 'default' activity to gateway A."

The routing and activation paths still do that bare comparison. requestGatewayForAgent gets it right via registryBackendScopeKey(connectionId, profile) → conn:local::default, but only an owner that already carries a connectionId reaches it — and outside Bot Mode nothing does (session-states.ts:812 drops ownerRoute for sessions-mode tiles, and open-session.ts:97 never supplies one).

So "gateway per session" can't fully land while the primary is unaddressable: a per-session route pointing at the local connection still collapses the moment its profile name matches the primary's. If you're already introducing per-session connection identity here, giving the primary a real connection id (and making the three comparisons above connection-aware rather than name-aware) would close this at the same time.

Happy to send that as a separate PR if you'd rather keep this one scoped — just didn't want to land a competing mechanism underneath an active rewrite. Full reproduction, trace output, and analysis are in #94811.

@valvesss

Copy link
Copy Markdown
Author

Thanks for the detailed trace and analysis — agreed, this is squarely in the Layer-2 ("gateway per session") territory this PR opens.

The root cause is exactly as you describe: the primary carries no connection id, so the bare key === g.primaryProfile fast paths in gatewayForProfile / ensureGatewayForProfile / activeGatewayConnectionId collapse same-named session routes onto the primary socket the moment the remote is primary.

I reviewed #94864 and it looks like the right place for the fix: preserving a connection-qualified owner for ordinary (sessions-mode) tiles via knownSessionOwner and carrying ownerRoute through setSessionTileWorkspaceScope / openSessionTile closes the collapse whether the remote or the local side is primary. We're keeping #94457 scoped to the source picker + Layer-2 foundation and will rebase it on top of main once #94864 lands — the only file both PRs touch is wiring.tsx, and the changes are in non-overlapping regions (imports/~L355 there, the onStartSessionOnSource hook ~L1040 here), so the rebase should be clean.

Also acknowledging cmoiccool's independent reproduction and the acceptance matrix — the inverse-topology row (remote primary + local secondary, both default) is exactly the scenario your knownSessionOwner ambiguity handling and connection-qualified routes are built for.

Happy to coordinate the landing order (#94864 → #94914 → #94457 rebased). Let me know if you'd prefer any of the ownership plumbing folded into #94457's per-session rework instead.

@valvesss

Copy link
Copy Markdown
Author

Small correction to my note above, to keep the coordination honest: it's actually three files both branches touch, not one — wiring.tsx, store/session.ts, and store/session.test.ts.

I did a git merge-tree trial-merge of #94864's head against this branch's head:

  • wiring.tsx + store/session.ts — the changes land in distinct regions, so git auto-merges them cleanly (your import/owner-resolution hunks vs. our onStartSessionOnSource hook and $foreignGatewaySessions additions).
  • store/session.test.ts — the one real overlap: both PRs add a describe(...) block at the same insertion point (knownSessionOwner reconciliation there, foreign gateway session aggregation here). Trivial static resolution — keep both blocks.

So the rebase stays small, and I'll take it once you're in (or fold session.test.ts's two blocks together if you'd prefer us to land first). No blocker either way.

@alt-glitch alt-glitch added the sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state label Aug 25, 2026
valvesss and others added 2 commits August 25, 2026 17:52
…on tooltip wiring)

The picker's trigger sits inside a Tip in the real sidebar; a shared asChild
button must not swallow the pointerdown that opens the menu. Adds that
regression case and gates the empty-state assertion with waitFor to avoid
flakiness under Radix pointer-capture.
startSessionOnSource still called ensureGatewayAgent, which re-homed the
ConnectionSwitcher and profile rail. Pin the draft to the chosen source
and route create/submit/resume on that socket instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
@valvesss
valvesss force-pushed the feat/new-session-source-picker branch from ee6ec3a to 62606d5 Compare August 25, 2026 22:15
Isolated serve inherited dashboard.public_url from ~/.hermes/.env (dotenv override), engaged the OAuth gate, and rejected the desktop session token on /api/ws. HERMES_DESKTOP=1 now ignores that public URL so loopback children stay in token mode; lockfile protocol 2 forces a respawn of already-gated processes. Sessions-mode tiles keep ownerRoute so RPCs hit the chosen gateway, and the sidebar origin tag names it.

Co-authored-by: Cursor <cursoragent@cursor.com>
@valvesss

Copy link
Copy Markdown
Author

Closing in favor of a product pivot.

Why: Gateway belongs on the project (host + folder at create time; sessions inherit), not on a per-click New session source picker. That UX was the wrong home for multi-gateway.

Continues in: #95179 — keeps the useful multi-gateway infra from this PR (pin without re-home, origin tags, foreign sessions / ownerRoute, SSH token-mode fixes) and ships the project-bound model instead.

@valvesss

Copy link
Copy Markdown
Author

Superseded by #95179 (project as gateway home).

@valvesss valvesss closed this Aug 26, 2026
@valvesss
valvesss deleted the feat/new-session-source-picker branch September 3, 2026 12:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants