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..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 @@ -1,8 +1,9 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +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, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { 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 @@ -11,10 +12,40 @@ import { ProfileRail } from './profile-switcher' const navigate = vi.fn() +const { gatewayState, requestGateway, sortableIds } = vi.hoisted(() => ({ + gatewayState: { current: { id: 'pc' } }, + 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 })) +vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({ + useGatewayRequest: () => ({ gateway: gatewayState.current, requestGateway }) +})) + vi.mock('@/i18n', () => ({ useI18n: () => ({ t: { @@ -48,6 +79,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, @@ -62,6 +94,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(), @@ -84,16 +117,64 @@ 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, + $profileColors, + $profileScope, + $profiles, + $profilesConnectionId, + selectProfile +} = await import('@/store/profile') + +const { $profileRemoteOverrides } = await import('@/store/profile-remote-override') +const { $connection } = await import('@/store/session') + +const profiles = $profiles as ReturnType< + typeof atom> +> + +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 }) + vi.mocked(selectProfile).mockClear() +}) afterEach(() => { cleanup() hasMultipleConnections.set(false) profiles.set([{ is_default: true, name: 'default' }]) + profileScope.set('default') + activeGatewayProfile.set('default') + profileColors.set({}) + $profileRemoteOverrides.set({}) + profilesConnectionId.set('pc') + connection.set({ connectionId: 'pc' }) }) 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', + 'founder', + 'picasso' + ]) + }) + it('deep-links to the unified Settings → Gateways page from the rail', () => { render() @@ -121,6 +202,193 @@ 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.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' }, + { 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') + 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([ + { 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() + + fireEvent.click(allProfiles) + + expect(selectProfile).toHaveBeenCalledWith('default') + }) + 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..e6606f312491c 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' @@ -59,6 +60,7 @@ import { $profileCreateRequest, $profileOrder, $profiles, + $profilesConnectionId, $profileScope, ALL_PROFILES, normalizeProfileKey, @@ -76,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' @@ -87,45 +90,67 @@ 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( + 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) { + // 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 + } + + 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: 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. +// 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 const profiles = useStore($profiles) + const profilesConnectionId = useStore($profilesConnectionId) + const connection = useStore($connection) const scope = useStore($profileScope) const gatewayProfile = useStore($activeGatewayProfile) const order = useStore($profileOrder) @@ -173,13 +198,24 @@ export function ProfileRail() { const isAll = scope === ALL_PROFILES const activeKey = normalizeProfileKey(gatewayProfile) const defaultProfile = profiles.find(profile => profile.is_default) - const onDefault = !isAll && activeKey === 'default' + const defaultLabel = defaultProfile ? profileLabel(defaultProfile) : '' + const rosterCurrent = profilesConnectionId === (connection?.connectionId ?? null) + const exactActiveProfile = profiles.find(profile => normalizeProfileKey(profile.name) === activeKey) + + const activeProfile = isAll || !rosterCurrent + ? 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) !== activeProfileKey) + const multiProfile = profiles.length > 1 // distance constraint: a small drag reorders, a tap still selects the profile. @@ -213,11 +249,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 +293,35 @@ 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))} - /> - ) : ( - setShowAllProfiles(true)} /> - ))} - - {/* Single-profile: the active default's home icon next to the create +. */} - {!multiProfile && defaultProfile && ( + {multiProfile && isAll && ( selectProfile(defaultProfile.name)} + glyph="layers" + label={p.allProfiles} + onSelect={() => + 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 && ( + selectProfile(activeProfile.name)} + profileName={activeProfile.name} /> )} @@ -289,13 +330,20 @@ export function ProfileRail() { // reorder, no long-press recolor, no per-square context menu — Manage // covers rename/delete at this scale.
+ {rosterCurrent && defaultProfile && (isAll || !onDefault) && ( + selectProfile(defaultProfile.name)} + /> + )} setCreateOpen(true)} onImport={() => void runImportProfileFlow()} onSelect={selectProfile} - profiles={named} + profiles={compactNamed} />
) : ( @@ -303,34 +351,61 @@ 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 && + (onDefault ? ( + setShowAllProfiles(true)} + profileName={defaultProfile.name} + /> + ) : ( + 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 => ( - openRemoteOverrideDialog(profile.name)} - onDelete={() => setPendingDelete(profile)} - onEditSoul={() => setPendingSoul(profile.name)} - onRecolor={color => setProfileColor(profile.name, color)} - onRename={() => setPendingRename(profile)} - onSelect={() => selectProfile(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} + /> + ) + )}
@@ -610,6 +685,111 @@ interface ProfilePillProps { onSelect: () => 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 ( @@ -635,6 +815,7 @@ interface ProfileSquareProps { active: boolean color: null | string label: string + profileName: string onSelect: () => void onRecolor: (color: null | string) => void onRename: () => void @@ -667,6 +848,7 @@ function ProfileSquare({ onRecolor, onRename, onSelect, + profileName, remoteHost }: ProfileSquareProps) { const { t } = useI18n() @@ -677,10 +859,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 }) @@ -737,7 +919,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 @@ -791,7 +973,7 @@ function ProfileSquare({ - {remoteHost ? `${label} · ${p.remoteOverride.badge(remoteHost)}` : label} + {label} 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/apps/desktop/src/store/profile.test.ts b/apps/desktop/src/store/profile.test.ts index c2e7cf36c02ba..7a311d721ff41 100644 --- a/apps/desktop/src/store/profile.test.ts +++ b/apps/desktop/src/store/profile.test.ts @@ -23,6 +23,7 @@ vi.mock('@/store/starmap', () => ({ 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 diff --git a/website/docs/user-guide/multi-connection-desktop.md b/website/docs/user-guide/multi-connection-desktop.md index 7029d7304227e..2b9e40d9f917b 100644 --- a/website/docs/user-guide/multi-connection-desktop.md +++ b/website/docs/user-guide/multi-connection-desktop.md @@ -171,8 +171,15 @@ 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 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**; 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.