From f4ac2b6fa548e22b5c6cefaa7ad80f53f893c00a Mon Sep 17 00:00:00 2001 From: Casey Date: Mon, 24 Aug 2026 23:32:10 -0500 Subject: [PATCH 1/4] feat(desktop): expand active profile identity --- .../sidebar/profile-rail-connect.test.tsx | 130 ++++++++++- .../src/app/chat/sidebar/profile-switcher.tsx | 208 +++++++++++++++--- .../src/store/gateway-profile-request.test.ts | 37 +++- apps/desktop/src/store/gateway.ts | 12 + .../user-guide/multi-connection-desktop.md | 9 +- 5 files changed, 355 insertions(+), 41 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx b/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx index a559609564ac1..4c8177a0370d0 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx @@ -1,8 +1,8 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { atom } from 'nanostores' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ProfileRail } from './profile-switcher' +import { mergeCompactProfileOrder, ProfileRail } from './profile-switcher' // The rail's discoverability pills are navigation, not identity — assert the // multi-gateway entry point deep-links to Settings → Connections instead of @@ -11,10 +11,19 @@ import { ProfileRail } from './profile-switcher' const navigate = vi.fn() +const { gatewayState, requestGateway } = vi.hoisted(() => ({ + gatewayState: { current: { id: 'pc' } }, + requestGateway: vi.fn() +})) + vi.mock('react-router', () => ({ useNavigate: () => navigate })) +vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({ + useGatewayRequest: () => ({ gateway: gatewayState.current, requestGateway }) +})) + vi.mock('@/i18n', () => ({ useI18n: () => ({ t: { @@ -84,16 +93,38 @@ vi.mock('../../profiles/rename-profile-dialog', () => ({ RenameProfileDialog: () const { $hasMultipleConnections } = await import('@/store/connections') const hasMultipleConnections = $hasMultipleConnections as ReturnType> -const { $profiles } = await import('@/store/profile') -const profiles = $profiles as ReturnType>> +const { $activeGatewayProfile, $profileScope, $profiles } = await import('@/store/profile') + +const profiles = $profiles as ReturnType< + typeof atom> +> + +const profileScope = $profileScope as ReturnType> +const activeGatewayProfile = $activeGatewayProfile as ReturnType> + +beforeEach(() => { + gatewayState.current = { id: 'pc' } + requestGateway.mockReset() + requestGateway.mockResolvedValue({ found: false }) +}) afterEach(() => { cleanup() hasMultipleConnections.set(false) profiles.set([{ is_default: true, name: 'default' }]) + profileScope.set('default') + activeGatewayProfile.set('default') }) describe('ProfileRail multi-gateway entry point', () => { + it('reorders visible compact profiles without moving the hidden active profile', () => { + expect(mergeCompactProfileOrder(['picasso', 'founder', 'writer'], ['picasso', 'writer'], 'writer', 'picasso')).toEqual([ + 'writer', + 'founder', + 'picasso' + ]) + }) + it('deep-links to the unified Settings → Gateways page from the rail', () => { render() @@ -121,6 +152,95 @@ describe('ProfileRail multi-gateway entry point', () => { expect(screen.getByRole('button', { name: 'Manage profiles…' })).toBeTruthy() }) + it('shows the default owner name and initial instead of a generic home glyph', () => { + profiles.set([ + { display_name: 'Clyde', is_default: true, name: 'default' }, + { is_default: false, name: 'picasso' } + ]) + render() + + const owner = screen.getByRole('button', { name: 'Clyde' }) + + expect(within(owner).getByText('C')).toBeTruthy() + expect(within(owner).getByText('Clyde')).toBeTruthy() + }) + + it('expands the active named profile and collapses the inactive owner to a circular initial', () => { + activeGatewayProfile.set('picasso') + profileScope.set('picasso') + profiles.set([ + { display_name: 'Clyde', is_default: true, name: 'default' }, + { display_name: 'Picasso', is_default: false, name: 'picasso' }, + { display_name: 'Founder', is_default: false, name: 'founder' } + ]) + render() + + const active = screen.getByRole('button', { name: 'Picasso' }) + const owner = screen.getByRole('button', { name: 'Clyde' }) + const founder = screen.getByRole('button', { name: 'Founder' }) + + expect(within(active).getByText('Picasso')).toBeTruthy() + expect(active.getAttribute('aria-pressed')).toBe('true') + expect(owner.querySelector('[data-slot="profile-owner-compact"]')).toBeTruthy() + expect(within(owner).getByText('C')).toBeTruthy() + expect(within(owner).queryByText('Clyde')).toBeNull() + expect(within(founder).getByText('F')).toBeTruthy() + }) + + it('uses the default profile avatar when the gateway has one', async () => { + requestGateway.mockResolvedValue({ data: 'data:image/png;base64,YXZhdGFy', found: true }) + profiles.set([ + { display_name: 'Clyde', is_default: true, name: 'default' }, + { is_default: false, name: 'picasso' } + ]) + render() + + await waitFor(() => + expect(requestGateway).toHaveBeenCalledWith('profiles.get_asset', { asset: 'avatar', name: 'default' }) + ) + + const owner = screen.getByRole('button', { name: 'Clyde' }) + const avatar = owner.querySelector('img') + + expect(avatar?.getAttribute('src')).toBe('data:image/png;base64,YXZhdGFy') + expect(within(owner).queryByText('C')).toBeNull() + }) + + it('does not render the previous gateway owner while the next avatar loads', async () => { + requestGateway.mockResolvedValueOnce({ data: 'data:image/png;base64,cGM=', found: true }) + profiles.set([ + { display_name: 'Clyde', is_default: true, name: 'default' }, + { is_default: false, name: 'picasso' } + ]) + const { rerender } = render() + const owner = screen.getByRole('button', { name: 'Clyde' }) + + await waitFor(() => expect(owner.querySelector('img')).toBeTruthy()) + + gatewayState.current = { id: 'forge' } + requestGateway.mockImplementationOnce(() => new Promise(() => undefined)) + rerender() + + const switchedOwner = screen.getByRole('button', { name: 'Clyde' }) + + expect(switchedOwner.querySelector('img')).toBeNull() + expect(within(switchedOwner).getByText('C')).toBeTruthy() + }) + + it('keeps the layers control in the all-profiles state', () => { + profileScope.set('*') + profiles.set([ + { display_name: 'Clyde', is_default: true, name: 'default' }, + { is_default: false, name: 'picasso' } + ]) + render() + + const allProfiles = screen.getByRole('button', { name: 'All profiles' }) + + expect(allProfiles.querySelector('.codicon-layers')).toBeTruthy() + expect(within(allProfiles).queryByText('Clyde')).toBeNull() + }) + it('keeps thirteen profiles direct and condenses the fourteenth', () => { profiles.set([ { is_default: true, name: 'default' }, diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index a29e75453aad3..013fade05ed0f 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -22,6 +22,7 @@ import { useStore } from '@nanostores/react' import { useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router' +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' import { CodeEditor } from '@/components/chat/code-editor' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' @@ -117,11 +118,30 @@ const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, trans return { ...transform, x: Math.min(maxX, Math.max(minX, snapped)), y: 0 } } -// Arc-Spaces-style profile rail at the sidebar foot: a default↔all toggle pinned -// left, the colored named profiles scrolling between, and Manage pinned right. -// The active profile pops in its own color — the "where am I" cue. Gateway -// identity lives in the statusbar, so this strip remains entirely available to -// profiles regardless of how many backends are registered. +export function mergeCompactProfileOrder( + allIds: string[], + compactIds: string[], + activeId: string, + overId: string +): string[] { + const from = compactIds.indexOf(activeId) + const to = compactIds.indexOf(overId) + + if (from < 0 || to < 0 || from === to) { + return allIds + } + + const reordered = arrayMove(compactIds, from, to) + const compact = new Set(compactIds) + let cursor = 0 + + return allIds.map(id => (compact.has(id) ? reordered[cursor++] : id)) +} + +// Arc-Spaces-style profile rail at the sidebar foot: the active identity expands +// on the left, inactive profiles stay compact in the scrolling strip, and Manage +// remains pinned right. The default profile's circular initial marks it as the +// machine owner; ordinary profiles use rounded squares. export function ProfileRail() { const { t } = useI18n() const p = t.profiles @@ -173,13 +193,20 @@ export function ProfileRail() { const isAll = scope === ALL_PROFILES const activeKey = normalizeProfileKey(gatewayProfile) const defaultProfile = profiles.find(profile => profile.is_default) + const defaultLabel = defaultProfile ? profileLabel(defaultProfile) : '' const onDefault = !isAll && activeKey === 'default' + const activeProfile = isAll + ? null + : profiles.find(profile => normalizeProfileKey(profile.name) === activeKey) ?? defaultProfile ?? null + const named = sortByProfileOrder( profiles.filter(profile => !profile.is_default), order ) + const compactNamed = isAll ? named : named.filter(profile => normalizeProfileKey(profile.name) !== activeKey) + const multiProfile = profiles.length > 1 // distance constraint: a small drag reorders, a tap still selects the profile. @@ -213,11 +240,11 @@ export function ProfileRail() { } const ids = named.map(profile => profile.name) - const from = ids.indexOf(String(active.id)) - const to = ids.indexOf(String(over.id)) + const compactIds = compactNamed.map(profile => profile.name) + const next = mergeCompactProfileOrder(ids, compactIds, String(active.id), String(over.id)) - if (from >= 0 && to >= 0) { - setProfileOrder(arrayMove(ids, from, to)) + if (next !== ids) { + setProfileOrder(next) reorderCommitHaptic() } } @@ -257,30 +284,25 @@ export function ProfileRail() { return (
- {/* One button toggles default ↔ all: home face when scoped to a profile, - layers face when showing everything. Pinned left like Manage is right. - Hidden until a second profile exists. */} {multiProfile && - (defaultProfile ? ( - // On default → toggle to all. Anywhere else (all view or a named - // profile) → return to default. So leaving a profile never lands on all. - (onDefault ? setShowAllProfiles(true) : selectProfile(defaultProfile.name))} + (isAll ? ( + setShowAllProfiles(true)} /> + ) : activeProfile ? ( + (activeProfile.is_default ? setShowAllProfiles(true) : selectProfile(activeProfile.name))} + profileName={activeProfile.name} /> - ) : ( - setShowAllProfiles(true)} /> - ))} + ) : null)} - {/* Single-profile: the active default's home icon next to the create +. */} + {/* Single-profile: the active default owner's identity next to create. */} {!multiProfile && defaultProfile && ( - selectProfile(defaultProfile.name)} + profileName={defaultProfile.name} /> )} @@ -289,13 +311,20 @@ export function ProfileRail() { // reorder, no long-press recolor, no per-square context menu — Manage // covers rename/delete at this scale.
+ {defaultProfile && (isAll || !onDefault) && ( + selectProfile(defaultProfile.name)} + /> + )} setCreateOpen(true)} onImport={() => void runImportProfileFlow()} onSelect={selectProfile} - profiles={named} + profiles={compactNamed} />
) : ( @@ -303,6 +332,14 @@ export function ProfileRail() { className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" ref={scrollRef} > + {multiProfile && defaultProfile && (isAll || !onDefault) && ( + selectProfile(defaultProfile.name)} + /> + )} + {multiProfile && ( - profile.name)} strategy={horizontalListSortingStrategy}> + profile.name)} strategy={horizontalListSortingStrategy}> {/* relative → the strip is the dragged square's offsetParent, so the clamp modifier bounds drags to the occupied cells (not the +). */}
- {named.map(profile => ( + {compactNamed.map(profile => ( void } +function ActiveProfilePill({ + color, + label, + onSelect, + profileName +}: { + color: null | string + label: string + onSelect: () => void + profileName: string +}) { + const { gateway, requestGateway } = useGatewayRequest() + + const [avatar, setAvatar] = useState<{ + data: string + gateway: typeof gateway + profileName: string + } | null>(null) + // A gateway/profile switch renders before its passive effects run. Scope the + // committed image to the identity that fetched it so the previous machine's + // owner can never flash in the new machine's rail during that first paint. + + const avatarSrc = avatar?.gateway === gateway && avatar.profileName === profileName ? avatar.data : null + + useEffect(() => { + let live = true + + if (!gateway) { + return () => { + live = false + } + } + + void requestGateway<{ data?: string; found?: boolean }>('profiles.get_asset', { + asset: 'avatar', + name: profileName + }) + .then(asset => { + if (live && asset.found && asset.data) { + setAvatar({ data: asset.data, gateway, profileName }) + } + }) + .catch(() => undefined) + + return () => { + live = false + } + }, [gateway, profileName, requestGateway]) + + return ( + + + + ) +} + +function OwnerProfileCompact({ + color, + label, + onSelect +}: { + color: null | string + label: string + onSelect: () => void +}) { + const hue = color ?? 'var(--ui-text-quaternary)' + const initial = label.replace(/[^a-z0-9]/gi, '').charAt(0) || '?' + + return ( + + + + ) +} + function ProfilePill({ active, glyph, label, onSelect }: ProfilePillProps) { return ( diff --git a/apps/desktop/src/store/gateway-profile-request.test.ts b/apps/desktop/src/store/gateway-profile-request.test.ts index 2aa098f5debf5..fe512b051c137 100644 --- a/apps/desktop/src/store/gateway-profile-request.test.ts +++ b/apps/desktop/src/store/gateway-profile-request.test.ts @@ -42,7 +42,7 @@ vi.mock('@/hermes', () => ({ }, setApiRequestConnection: vi.fn() })) -vi.mock('@/store/session', () => ({ setGatewayState: vi.fn() })) +vi.mock('@/store/session', () => ({ setConnection: vi.fn(), setGatewayState: vi.fn() })) vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() })) const { @@ -89,6 +89,41 @@ afterEach(() => { }) describe('requestGatewayForProfile', () => { + it('reuses the active registry agent instead of spawning a same-named local profile', async () => { + const primary = makePrimary() + const getConnection = vi.fn(async (profile: null | string) => ({ port: 4242, profile, token: 'local-token' })) + + const getConnectionFor = vi.fn(async ({ connectionId, profile }) => ({ + connectionId, + port: 5151, + profile, + token: 'registry-token' + })) + + setPrimaryGateway(primary as never, 'default') + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { + getConnection, + getConnectionFor, + getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }) => ({ + ok: true as const, + wsUrl: `ws://${connectionId}/${profile}` + })), + touchBackend: vi.fn(async () => undefined) + } + + await ensureGatewayForAgent('forge', 'sagan') + getConnection.mockClear() + getConnectionFor.mockClear() + + const result = await requestGatewayForProfile('sagan', 'session.list', { limit: 20 }) + + expect(result).toEqual({ method: 'session.list', params: { limit: 20 } }) + expect(getConnectionFor).not.toHaveBeenCalled() + expect(getConnection).not.toHaveBeenCalled() + expect(secondaryGateways).toHaveLength(1) + expect(secondaryGateways[0].request).toHaveBeenCalledWith('session.list', { limit: 20 }) + }) + it('requests through a pooled profile gateway without changing the active gateway', async () => { const primary = makePrimary() setPrimaryGateway(primary as never, 'default') diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index 1f578ecd6097c..66f3d28b45aac 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -627,6 +627,18 @@ export async function requestGatewayForProfile( timeoutMs?: number, signal?: AbortSignal ): Promise { + const key = normKey(profile) + const activeConnectionId = activeGatewayConnectionId() + + // Legacy/session call sites can still carry only a profile name. When that + // profile is the ACTIVE registry agent, its source is unambiguous — keep the + // request on that composite route. Falling through to gatewayForProfile() + // keys the pool by the bare name and can spawn a same-named LOCAL backend + // after a remote switch (Forge Sagan/Bellow → "Profile X no longer exists"). + if (activeConnectionId && key === activeGatewayProfileKey()) { + return requestGatewayForAgent(activeConnectionId, key, method, params, timeoutMs, signal) + } + const route = await gatewayForProfile(profile, true) try { diff --git a/website/docs/user-guide/multi-connection-desktop.md b/website/docs/user-guide/multi-connection-desktop.md index 7029d7304227e..d610263796446 100644 --- a/website/docs/user-guide/multi-connection-desktop.md +++ b/website/docs/user-guide/multi-connection-desktop.md @@ -171,8 +171,13 @@ that live on one gateway. from two gateways to a larger fleet without turning backends into profile-like glyphs or crowding profile actions out of the rail. - Selecting a gateway restores the last profile used there. The profile rail - then shows only that gateway's profiles; the home pill returns to its default - profile and the layers pill shows **All profiles on this gateway**. + then shows only that gateway's profiles. Its default-profile pill shows that + profile's avatar and display name when active. Whichever profile is selected + expands to its avatar and full display name; the inactive profiles collapse to + compact initials. The default profile uses a circular, ringed initial while + inactive so it remains recognizable as the machine owner, while ordinary + profiles use rounded-square initials. Hover text is the profile name only. + The layers pill shows **All profiles on this gateway**. **Cmd/Ctrl+1–9** continue to switch profiles within the active gateway. - The selected gateway survives a quit and relaunch only when **Settings → Gateways → At startup, return to Sessions on the last-used gateway** is on. From 8cd92411c32dad29df16f9746c719e1ac669dd32 Mon Sep 17 00:00:00 2001 From: Casey Date: Mon, 24 Aug 2026 23:48:34 -0500 Subject: [PATCH 2/4] fix(desktop): scope owner identity to gateway roster --- .../sidebar/profile-rail-connect.test.tsx | 36 ++++++++++++++++++- .../src/app/chat/sidebar/profile-switcher.tsx | 30 ++++++++++------ apps/desktop/src/store/profile.test.ts | 11 ++++++ apps/desktop/src/store/profile.ts | 10 +++++- 4 files changed, 74 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx b/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx index 4c8177a0370d0..916b2fcff4b80 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx @@ -57,6 +57,7 @@ vi.mock('@/store/profile', () => ({ $profileCreateRequest: atom(0), $profileOrder: atom([]), $profiles: atom([{ is_default: true, name: 'default' }]), + $profilesConnectionId: atom('pc'), $profileScope: atom('default'), ALL_PROFILES: '*', normalizeProfileKey: (name: string) => name, @@ -71,6 +72,7 @@ vi.mock('@/store/profile', () => ({ })) vi.mock('@/store/connections', () => ({ $hasMultipleConnections: atom(false) })) +vi.mock('@/store/session', () => ({ $connection: atom({ connectionId: 'pc' }) })) vi.mock('@/store/profile-share', () => ({ runExportProfileFlow: vi.fn(), @@ -93,7 +95,8 @@ vi.mock('../../profiles/rename-profile-dialog', () => ({ RenameProfileDialog: () const { $hasMultipleConnections } = await import('@/store/connections') const hasMultipleConnections = $hasMultipleConnections as ReturnType> -const { $activeGatewayProfile, $profileScope, $profiles } = await import('@/store/profile') +const { $activeGatewayProfile, $profileScope, $profiles, $profilesConnectionId } = await import('@/store/profile') +const { $connection } = await import('@/store/session') const profiles = $profiles as ReturnType< typeof atom> @@ -101,6 +104,8 @@ const profiles = $profiles as ReturnType< const profileScope = $profileScope as ReturnType> const activeGatewayProfile = $activeGatewayProfile as ReturnType> +const profilesConnectionId = $profilesConnectionId as ReturnType> +const connection = $connection as ReturnType> beforeEach(() => { gatewayState.current = { id: 'pc' } @@ -114,6 +119,8 @@ afterEach(() => { profiles.set([{ is_default: true, name: 'default' }]) profileScope.set('default') activeGatewayProfile.set('default') + profilesConnectionId.set('pc') + connection.set({ connectionId: 'pc' }) }) describe('ProfileRail multi-gateway entry point', () => { @@ -187,6 +194,33 @@ describe('ProfileRail multi-gateway entry point', () => { expect(within(founder).getByText('F')).toBeTruthy() }) + it('uses is_default to recognize a root-keyed machine owner', () => { + activeGatewayProfile.set('root') + profileScope.set('root') + profiles.set([ + { display_name: 'Bellow', is_default: true, name: 'root' }, + { display_name: 'Sagan', is_default: false, name: 'sagan' } + ]) + render() + + const ownerButtons = screen.getAllByRole('button', { name: 'Bellow' }) + const owner = ownerButtons[0]! + + expect(ownerButtons).toHaveLength(1) + expect(within(owner).getByText('Bellow')).toBeTruthy() + }) + + it('does not flash the previous gateway owner name while the next roster loads', () => { + profiles.set([ + { display_name: 'Clyde', is_default: true, name: 'default' }, + { display_name: 'Picasso', is_default: false, name: 'picasso' } + ]) + connection.set({ connectionId: 'forge' }) + render() + + expect(screen.queryByRole('button', { name: 'Clyde' })).toBeNull() + }) + it('uses the default profile avatar when the gateway has one', async () => { requestGateway.mockResolvedValue({ data: 'data:image/png;base64,YXZhdGFy', found: true }) profiles.set([ diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 013fade05ed0f..fc67937f0e3fd 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -60,6 +60,7 @@ import { $profileCreateRequest, $profileOrder, $profiles, + $profilesConnectionId, $profileScope, ALL_PROFILES, normalizeProfileKey, @@ -77,6 +78,7 @@ import { refreshProfileRemoteOverrides } from '@/store/profile-remote-override' import { runExportProfileFlow, runImportProfileFlow } from '@/store/profile-share' +import { $connection } from '@/store/session' import type { ProfileInfo } from '@/types/hermes' import { CreateProfileDialog } from '../../profiles/create-profile-dialog' @@ -146,6 +148,8 @@ export function ProfileRail() { const { t } = useI18n() const p = t.profiles const profiles = useStore($profiles) + const profilesConnectionId = useStore($profilesConnectionId) + const connection = useStore($connection) const scope = useStore($profileScope) const gatewayProfile = useStore($activeGatewayProfile) const order = useStore($profileOrder) @@ -194,18 +198,22 @@ export function ProfileRail() { const activeKey = normalizeProfileKey(gatewayProfile) const defaultProfile = profiles.find(profile => profile.is_default) const defaultLabel = defaultProfile ? profileLabel(defaultProfile) : '' - const onDefault = !isAll && activeKey === 'default' + const rosterCurrent = profilesConnectionId === (connection?.connectionId ?? null) + const exactActiveProfile = profiles.find(profile => normalizeProfileKey(profile.name) === activeKey) - const activeProfile = isAll + const activeProfile = isAll || !rosterCurrent ? null - : profiles.find(profile => normalizeProfileKey(profile.name) === activeKey) ?? defaultProfile ?? null + : exactActiveProfile ?? (activeKey === 'default' ? defaultProfile : null) ?? null + + const onDefault = activeProfile?.is_default === true + const activeProfileKey = activeProfile ? normalizeProfileKey(activeProfile.name) : null const named = sortByProfileOrder( profiles.filter(profile => !profile.is_default), order ) - const compactNamed = isAll ? named : named.filter(profile => normalizeProfileKey(profile.name) !== activeKey) + const compactNamed = isAll ? named : named.filter(profile => normalizeProfileKey(profile.name) !== activeProfileKey) const multiProfile = profiles.length > 1 @@ -297,12 +305,12 @@ export function ProfileRail() { ) : null)} {/* Single-profile: the active default owner's identity next to create. */} - {!multiProfile && defaultProfile && ( + {!multiProfile && activeProfile?.is_default && ( selectProfile(defaultProfile.name)} - profileName={defaultProfile.name} + color={resolveProfileColor(profileLabel(activeProfile), colors)} + label={profileLabel(activeProfile)} + onSelect={() => selectProfile(activeProfile.name)} + profileName={activeProfile.name} /> )} @@ -311,7 +319,7 @@ export function ProfileRail() { // reorder, no long-press recolor, no per-square context menu — Manage // covers rename/delete at this scale.
- {defaultProfile && (isAll || !onDefault) && ( + {rosterCurrent && defaultProfile && (isAll || !onDefault) && ( - {multiProfile && defaultProfile && (isAll || !onDefault) && ( + {multiProfile && rosterCurrent && defaultProfile && (isAll || !onDefault) && ( ({ resetStarmapGraph })) const { $activeGatewayProfile, $profiles, + $profilesConnectionId, ensureGatewayProfile, invalidateProfileListFetches, prewarmProfileBackend, @@ -59,6 +60,7 @@ beforeEach(() => { $activeGatewayProfile.set('default') $connection.set(localConn()) $profiles.set([]) + $profilesConnectionId.set(null) vi.stubGlobal('window', { hermesDesktop: { getConnection } }) vi.mocked(invalidateProfileScopedQueries).mockClear() resetStarmapGraph.mockClear() @@ -161,6 +163,15 @@ describe('prewarmProfileBackend (hover-intent pool spawn)', () => { }) describe('refreshProfiles shared rail list (#49289)', () => { + it('records which registry connection produced the cached roster', async () => { + $connection.set(remoteConn({ connectionId: 'forge' })) + vi.mocked(getProfiles).mockResolvedValueOnce({ profiles: [profile('default', true), profile('sagan')] }) + + await refreshProfiles() + + expect($profilesConnectionId.get()).toBe('forge') + }) + it('removes a deleted profile from the shared $profiles cache after Manage Profiles refreshes', async () => { $profiles.set([profile('default', true), profile('test1')]) vi.mocked(getProfiles).mockResolvedValueOnce({ profiles: [profile('default', true)] }) diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index 4ad397744f008..95bf5b72062c8 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -21,7 +21,7 @@ import { openGatewayForProfile } from '@/store/gateway' import { notifyRemoteOverrideAuthFailure } from '@/store/profile-remote-override' -import { setConnection } from '@/store/session' +import { $connection, setConnection } from '@/store/session' import { resetStarmapGraph } from '@/store/starmap' import type { ProfileInfo } from '@/types/hermes' @@ -49,6 +49,10 @@ export const $activeProfile = atom('default') // Cached profile list for the picker. Refreshed lazily; the dropdown also // re-fetches on open so a profile created elsewhere shows up. export const $profiles = atom([]) +// Connection id that produced the cached roster. The list deliberately stays +// visible while another gateway loads, so identity-sensitive controls compare +// this source before showing a machine owner's display name or avatar. +export const $profilesConnectionId = atom(null) export function setActiveProfile(name: string): void { $activeProfile.set(name || 'default') @@ -72,10 +76,14 @@ export function invalidateProfileListFetches(): void { export async function refreshProfiles(): Promise { const epoch = profileListEpoch + const connectionId = $connection.get()?.connectionId ?? null const { profiles } = await getProfiles() if (epoch === profileListEpoch) { $profiles.set(profiles) + // Publish after the roster: consumers may briefly hide identity while the + // two atoms settle, but can never bless the PREVIOUS roster as current. + $profilesConnectionId.set(connectionId) } return profiles From 386f585975a02def40a01e5f3aa5ad44a65466dd Mon Sep 17 00:00:00 2001 From: Casey Date: Mon, 24 Aug 2026 23:58:32 -0500 Subject: [PATCH 3/4] fix(desktop): keep profile rail identity stable --- .../sidebar/profile-rail-connect.test.tsx | 81 ++++++++++++++++++- .../src/app/chat/sidebar/profile-switcher.tsx | 19 +++-- 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx b/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx index 916b2fcff4b80..6efd8bdb2b34b 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx @@ -1,3 +1,4 @@ +import type * as DndKitSortable from '@dnd-kit/sortable' import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { atom } from 'nanostores' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -11,11 +12,32 @@ import { mergeCompactProfileOrder, ProfileRail } from './profile-switcher' const navigate = vi.fn() -const { gatewayState, requestGateway } = vi.hoisted(() => ({ +const { gatewayState, requestGateway, sortableIds } = vi.hoisted(() => ({ gatewayState: { current: { id: 'pc' } }, - requestGateway: vi.fn() + requestGateway: vi.fn(), + sortableIds: [] as string[] })) +vi.mock('@dnd-kit/sortable', async importOriginal => { + const actual = await importOriginal() + + return { + ...actual, + useSortable: ({ id }: { id: string }) => { + sortableIds.push(id) + + return { + attributes: {}, + isDragging: false, + listeners: {}, + setNodeRef: vi.fn(), + transform: null, + transition: null + } + } + } +}) + vi.mock('react-router', () => ({ useNavigate: () => navigate })) @@ -95,7 +117,11 @@ vi.mock('../../profiles/rename-profile-dialog', () => ({ RenameProfileDialog: () const { $hasMultipleConnections } = await import('@/store/connections') const hasMultipleConnections = $hasMultipleConnections as ReturnType> -const { $activeGatewayProfile, $profileScope, $profiles, $profilesConnectionId } = await import('@/store/profile') + +const { $activeGatewayProfile, $profileColors, $profileScope, $profiles, $profilesConnectionId } = + await import('@/store/profile') + +const { $profileRemoteOverrides } = await import('@/store/profile-remote-override') const { $connection } = await import('@/store/session') const profiles = $profiles as ReturnType< @@ -103,12 +129,14 @@ const profiles = $profiles as ReturnType< > const profileScope = $profileScope as ReturnType> +const profileColors = $profileColors as ReturnType>> const activeGatewayProfile = $activeGatewayProfile as ReturnType> const profilesConnectionId = $profilesConnectionId as ReturnType> const connection = $connection as ReturnType> beforeEach(() => { gatewayState.current = { id: 'pc' } + sortableIds.length = 0 requestGateway.mockReset() requestGateway.mockResolvedValue({ found: false }) }) @@ -119,6 +147,8 @@ afterEach(() => { profiles.set([{ is_default: true, name: 'default' }]) profileScope.set('default') activeGatewayProfile.set('default') + profileColors.set({}) + $profileRemoteOverrides.set({}) profilesConnectionId.set('pc') connection.set({ connectionId: 'pc' }) }) @@ -194,6 +224,51 @@ describe('ProfileRail multi-gateway entry point', () => { expect(within(founder).getByText('F')).toBeTruthy() }) + it('uses stable profile keys for sortable identity when display names differ', () => { + profiles.set([ + { display_name: 'Clyde', is_default: true, name: 'default' }, + { display_name: 'Visual Artist', is_default: false, name: 'picasso' }, + { display_name: 'Company Founder', is_default: false, name: 'founder' } + ]) + render() + + expect(sortableIds).toEqual(expect.arrayContaining(['picasso', 'founder'])) + expect(sortableIds).not.toEqual(expect.arrayContaining(['Visual Artist', 'Company Founder'])) + }) + + it('keeps remote-profile labels and tooltips name-only', () => { + profiles.set([ + { display_name: 'Clyde', is_default: true, name: 'default' }, + { display_name: 'Founder', is_default: false, name: 'founder' } + ]) + $profileRemoteOverrides.set({ founder: { host: 'bigbox', url: 'https://bigbox.example' } }) + render() + + expect(screen.getByRole('button', { name: 'Founder' })).toBeTruthy() + expect(screen.queryByRole('button', { name: /Runs on bigbox/ })).toBeNull() + }) + + it('resolves active and owner colors from profile keys instead of display names', () => { + profileColors.set({ default: '#123456', picasso: '#654321' }) + profiles.set([ + { display_name: 'Clyde', is_default: true, name: 'default' }, + { display_name: 'Visual Artist', is_default: false, name: 'picasso' } + ]) + const { unmount } = render() + + expect(within(screen.getByRole('button', { name: 'Clyde' })).getByText('C').style.color).toBe('') + unmount() + + activeGatewayProfile.set('picasso') + profileScope.set('picasso') + render() + + expect(within(screen.getByRole('button', { name: 'Visual Artist' })).getByText('V').style.color).toBe( + 'rgb(101, 67, 33)' + ) + expect(screen.getByRole('button', { name: 'Clyde' }).style.color).toBe('var(--ui-text-quaternary)') + }) + it('uses is_default to recognize a root-keyed machine owner', () => { activeGatewayProfile.set('root') profileScope.set('root') diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index fc67937f0e3fd..3307f57123768 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -297,7 +297,7 @@ export function ProfileRail() { setShowAllProfiles(true)} /> ) : activeProfile ? ( (activeProfile.is_default ? setShowAllProfiles(true) : selectProfile(activeProfile.name))} profileName={activeProfile.name} @@ -307,7 +307,7 @@ export function ProfileRail() { {/* Single-profile: the active default owner's identity next to create. */} {!multiProfile && activeProfile?.is_default && ( selectProfile(activeProfile.name)} profileName={activeProfile.name} @@ -321,7 +321,7 @@ export function ProfileRail() {
{rosterCurrent && defaultProfile && (isAll || !onDefault) && ( selectProfile(defaultProfile.name)} /> @@ -342,7 +342,7 @@ export function ProfileRail() { > {multiProfile && rosterCurrent && defaultProfile && (isAll || !onDefault) && ( selectProfile(defaultProfile.name)} /> @@ -373,6 +373,7 @@ export function ProfileRail() { onRecolor={color => setProfileColor(profile.name, color)} onRename={() => setPendingRename(profile)} onSelect={() => selectProfile(profile.name)} + profileName={profile.name} remoteHost={remoteOverrides[normalizeProfileKey(profile.name)]?.host ?? null} /> ))} @@ -785,6 +786,7 @@ interface ProfileSquareProps { active: boolean color: null | string label: string + profileName: string onSelect: () => void onRecolor: (color: null | string) => void onRename: () => void @@ -817,6 +819,7 @@ function ProfileSquare({ onRecolor, onRename, onSelect, + profileName, remoteHost }: ProfileSquareProps) { const { t } = useI18n() @@ -827,10 +830,10 @@ function ProfileSquare({ const suppressClick = useRef(false) // Hovering a square telegraphs the switch — start that profile's backend // spawn now so a cold click doesn't pay the full boot. - const { cancelPrewarm, startPrewarm } = useProfilePrewarm(label) + const { cancelPrewarm, startPrewarm } = useProfilePrewarm(profileName) const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ - id: label, + id: profileName, transition: RAIL_TRANSITION }) @@ -887,7 +890,7 @@ function ProfileSquare({ type="button" {...attributes} {...listeners} - aria-label={remoteHost ? `${label} — ${p.remoteOverride.badge(remoteHost)}` : label} + aria-label={label} aria-pressed={active} // Hold-to-recolor rides alongside the dnd pointer listener (call // it first so drag tracking still arms), then a timer opens the @@ -941,7 +944,7 @@ function ProfileSquare({ - {remoteHost ? `${label} · ${p.remoteOverride.badge(remoteHost)}` : label} + {label} From 7158896d69b4d43de751a78a992bff730bc6a844 Mon Sep 17 00:00:00 2001 From: Casey Date: Tue, 25 Aug 2026 10:40:21 -0500 Subject: [PATCH 4/4] [verified] fix(desktop): preserve active profile rail order --- .../sidebar/profile-rail-connect.test.tsx | 45 ++++++- .../src/app/chat/sidebar/profile-switcher.tsx | 125 +++++++++++------- .../user-guide/multi-connection-desktop.md | 8 +- 3 files changed, 124 insertions(+), 54 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx b/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx index 6efd8bdb2b34b..2aa3b3ad4f006 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx @@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-li import { atom } from 'nanostores' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mergeCompactProfileOrder, ProfileRail } from './profile-switcher' +import { clampRailDragX, mergeCompactProfileOrder, ProfileRail } from './profile-switcher' // The rail's discoverability pills are navigation, not identity — assert the // multi-gateway entry point deep-links to Settings → Connections instead of @@ -118,8 +118,14 @@ vi.mock('../../profiles/rename-profile-dialog', () => ({ RenameProfileDialog: () const { $hasMultipleConnections } = await import('@/store/connections') const hasMultipleConnections = $hasMultipleConnections as ReturnType> -const { $activeGatewayProfile, $profileColors, $profileScope, $profiles, $profilesConnectionId } = - await import('@/store/profile') +const { + $activeGatewayProfile, + $profileColors, + $profileScope, + $profiles, + $profilesConnectionId, + selectProfile +} = await import('@/store/profile') const { $profileRemoteOverrides } = await import('@/store/profile-remote-override') const { $connection } = await import('@/store/session') @@ -139,6 +145,7 @@ beforeEach(() => { sortableIds.length = 0 requestGateway.mockReset() requestGateway.mockResolvedValue({ found: false }) + vi.mocked(selectProfile).mockClear() }) afterEach(() => { @@ -154,6 +161,12 @@ afterEach(() => { }) describe('ProfileRail multi-gateway entry point', () => { + it('preserves continuous drag movement across a variable-width active slot', () => { + expect(clampRailDragX(73, -40, 160)).toBe(73) + expect(clampRailDragX(-55, -40, 160)).toBe(-40) + expect(clampRailDragX(190, -40, 160)).toBe(160) + }) + it('reorders visible compact profiles without moving the hidden active profile', () => { expect(mergeCompactProfileOrder(['picasso', 'founder', 'writer'], ['picasso', 'writer'], 'writer', 'picasso')).toEqual([ 'writer', @@ -224,6 +237,28 @@ describe('ProfileRail multi-gateway entry point', () => { expect(within(founder).getByText('F')).toBeTruthy() }) + it.each([ + ['picasso', ['Clyde', 'Picasso', 'Founder']], + ['founder', ['Clyde', 'Picasso', 'Founder']] + ])('expands %s in its saved rail slot without reordering profiles', (activeName, expectedOrder) => { + activeGatewayProfile.set(activeName) + profileScope.set(activeName) + profiles.set([ + { display_name: 'Clyde', is_default: true, name: 'default' }, + { display_name: 'Picasso', is_default: false, name: 'picasso' }, + { display_name: 'Founder', is_default: false, name: 'founder' } + ]) + render() + + const rail = screen.getByRole('group', { name: 'Profiles' }) + + const profileLabels = Array.from(rail.querySelectorAll('button')) + .map(button => button.getAttribute('aria-label')) + .filter(label => label && expectedOrder.includes(label)) + + expect(profileLabels).toEqual(expectedOrder) + }) + it('uses stable profile keys for sortable identity when display names differ', () => { profiles.set([ { display_name: 'Clyde', is_default: true, name: 'default' }, @@ -348,6 +383,10 @@ describe('ProfileRail multi-gateway entry point', () => { expect(allProfiles.querySelector('.codicon-layers')).toBeTruthy() expect(within(allProfiles).queryByText('Clyde')).toBeNull() + + fireEvent.click(allProfiles) + + expect(selectProfile).toHaveBeenCalledWith('default') }) it('keeps thirteen profiles direct and condenses the fourteenth', () => { diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 3307f57123768..e6606f312491c 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -90,34 +90,33 @@ import { ProfileRemoteOverrideDialog } from './profile-remote-override-dialog' import { useProfilePrewarm } from './use-profile-prewarm' import { useProfileRailRefreshOnActive } from './use-profile-rail-refresh-on-active' -const RAIL_GAP = 4 // px — matches gap-1 between squares. - // Past this many profiles the strip of colored squares stops scaling (tiny // drag targets, endless horizontal scroll), so the rail collapses to a compact // menu. Drag-reorder and long-press-recolor live only on the squares path. const PROFILE_DROPDOWN_THRESHOLD = 13 -// Neighbors reflow on RAIL_TRANSITION; the dragged square glides between -// snapped cells on the snappier DRAG_TRANSITION. Both come from the SHARED -// reorder primitive (lib/reorder.ts) so every reorder strip feels identical. +// Neighbors reflow on RAIL_TRANSITION; the dragged square follows the snappier +// DRAG_TRANSITION while closestCenter resolves variable-width slots. Both +// transitions come from the shared reorder primitive (lib/reorder.ts). const RAIL_TRANSITION = REORDER_RAIL_TRANSITION const DRAG_TRANSITION = REORDER_DRAG_TRANSITION_CSS -// The rail is a single horizontal strip of fixed cells. Pin drags to the x-axis -// (no cross-axis scrollbar), snap to whole cells so a square steps slot-to-slot -// instead of gliding, and clamp to the occupied strip so it can't float past the -// last profile onto the "+". -const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, transform }) => { +// The active profile is wider than compact initials, so the rail no longer has a +// uniform cell pitch. Keep drags continuous on the x-axis and let closestCenter +// resolve the variable-width positions, while clamping to the occupied strip. +export function clampRailDragX(x: number, minX: number, maxX: number): number { + return Math.min(maxX, Math.max(minX, x)) +} + +const clampToRail: Modifier = ({ containerNodeRect, draggingNodeRect, transform }) => { if (!draggingNodeRect || !containerNodeRect) { return { ...transform, y: 0 } } - const pitch = draggingNodeRect.width + RAIL_GAP const minX = containerNodeRect.left - draggingNodeRect.left const maxX = containerNodeRect.right - draggingNodeRect.right - const snapped = Math.round(transform.x / pitch) * pitch - return { ...transform, x: Math.min(maxX, Math.max(minX, snapped)), y: 0 } + return { ...transform, x: clampRailDragX(transform.x, minX, maxX), y: 0 } } export function mergeCompactProfileOrder( @@ -130,6 +129,8 @@ export function mergeCompactProfileOrder( const to = compactIds.indexOf(overId) if (from < 0 || to < 0 || from === to) { + // Referential equality is the caller's no-op signal: no reorder haptic and + // no persistence write when the drag did not produce a valid move. return allIds } @@ -292,17 +293,27 @@ export function ProfileRail() { return (
- {multiProfile && - (isAll ? ( - setShowAllProfiles(true)} /> - ) : activeProfile ? ( - (activeProfile.is_default ? setShowAllProfiles(true) : selectProfile(activeProfile.name))} - profileName={activeProfile.name} - /> - ) : null)} + {multiProfile && isAll && ( + + rosterCurrent && defaultProfile ? selectProfile(defaultProfile.name) : setShowAllProfiles(true) + } + /> + )} + + {/* Condensed rails cannot preserve individual slots, so keep the active + identity pinned beside the profile dropdown at that scale. */} + {multiProfile && condensed && !isAll && activeProfile && ( + (activeProfile.is_default ? setShowAllProfiles(true) : selectProfile(activeProfile.name))} + profileName={activeProfile.name} + /> + )} {/* Single-profile: the active default owner's identity next to create. */} {!multiProfile && activeProfile?.is_default && ( @@ -340,18 +351,26 @@ export function ProfileRail() { className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" ref={scrollRef} > - {multiProfile && rosterCurrent && defaultProfile && (isAll || !onDefault) && ( - selectProfile(defaultProfile.name)} - /> - )} + {multiProfile && rosterCurrent && defaultProfile && + (onDefault ? ( + setShowAllProfiles(true)} + profileName={defaultProfile.name} + /> + ) : ( + selectProfile(defaultProfile.name)} + /> + ))} {multiProfile && ( - {compactNamed.map(profile => ( - openRemoteOverrideDialog(profile.name)} - onDelete={() => setPendingDelete(profile)} - onEditSoul={() => setPendingSoul(profile.name)} - onRecolor={color => setProfileColor(profile.name, color)} - onRename={() => setPendingRename(profile)} - onSelect={() => selectProfile(profile.name)} - profileName={profile.name} - remoteHost={remoteOverrides[normalizeProfileKey(profile.name)]?.host ?? null} - /> - ))} + {named.map(profile => + !isAll && normalizeProfileKey(profile.name) === activeProfileKey ? ( + selectProfile(profile.name)} + profileName={profile.name} + /> + ) : ( + openRemoteOverrideDialog(profile.name)} + onDelete={() => setPendingDelete(profile)} + onEditSoul={() => setPendingSoul(profile.name)} + onRecolor={color => setProfileColor(profile.name, color)} + onRename={() => setPendingRename(profile)} + onSelect={() => selectProfile(profile.name)} + profileName={profile.name} + remoteHost={remoteOverrides[normalizeProfileKey(profile.name)]?.host ?? null} + /> + ) + )}
diff --git a/website/docs/user-guide/multi-connection-desktop.md b/website/docs/user-guide/multi-connection-desktop.md index d610263796446..2b9e40d9f917b 100644 --- a/website/docs/user-guide/multi-connection-desktop.md +++ b/website/docs/user-guide/multi-connection-desktop.md @@ -173,11 +173,13 @@ that live on one gateway. - Selecting a gateway restores the last profile used there. The profile rail then shows only that gateway's profiles. Its default-profile pill shows that profile's avatar and display name when active. Whichever profile is selected - expands to its avatar and full display name; the inactive profiles collapse to - compact initials. The default profile uses a circular, ringed initial while + expands in its existing rail slot to show its avatar and full display name; + selecting it never changes the profile order. The inactive profiles collapse + to compact initials. The default profile uses a circular, ringed initial while inactive so it remains recognizable as the machine owner, while ordinary profiles use rounded-square initials. Hover text is the profile name only. - The layers pill shows **All profiles on this gateway**. + The layers pill shows **All profiles on this gateway**; selecting it again + returns to the gateway's default machine owner. **Cmd/Ctrl+1–9** continue to switch profiles within the active gateway. - The selected gateway survives a quit and relaunch only when **Settings → Gateways → At startup, return to Sessions on the last-used gateway** is on.