diff --git a/apps/desktop/src/app/chat/sidebar/fleet-rail.ts b/apps/desktop/src/app/chat/sidebar/fleet-rail.ts index 5c2a3c70ad..f0bdc16d0d 100644 --- a/apps/desktop/src/app/chat/sidebar/fleet-rail.ts +++ b/apps/desktop/src/app/chat/sidebar/fleet-rail.ts @@ -1,6 +1,5 @@ import type { DesktopAgentRoster, DesktopConnectionKind, DesktopRegistryConnection } from '@/global' import { sortConnectionsForDisplay } from '@/lib/connection-display' -import type { ProfileInfo } from '@/types/hermes' // Pure grouping for the fleet profile rail: which gateways sit "at rest" // beside the active one, and which agents each of them carries. Kept free of @@ -52,13 +51,11 @@ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'bas export function buildRestGroups({ activeConnectionId, connections, - profilesByConnection, roster }: { activeConnectionId: null | string connections: readonly DesktopRegistryConnection[] roster: DesktopAgentRoster | null - profilesByConnection?: ReadonlyMap }): FleetGroup[] { const groups: FleetGroup[] = [] @@ -85,16 +82,7 @@ export function buildRestGroups({ const defaultRow = rows.find(row => row.profile === DEFAULT_PROFILE) - const cached = profilesByConnection?.get(connection.id) - - const profiles = cached - ? cached.map(profile => ({ - profile: profile.name, - handle: rows.find(row => row.profile === profile.name)?.handle - })) - : rows - - const named = profiles + const named = rows .filter(row => row.profile !== DEFAULT_PROFILE) .map(row => toAgent(row.profile, row.handle)) .sort((left, right) => collator.compare(left.profile, right.profile)) 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 4f8e3b286a..dc008fac1b 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 @@ -48,7 +48,6 @@ vi.mock('@/store/profile', () => ({ $profileCreateRequest: atom(0), $profileOrder: atom([]), $profiles: atom([{ is_default: true, name: 'default' }]), - $profilesByConnection: atom(new Map()), $profileScope: atom('default'), ALL_PROFILES: '*', normalizeProfileKey: (name: string) => name, diff --git a/apps/desktop/src/app/chat/sidebar/profile-rail-fleet.test.tsx b/apps/desktop/src/app/chat/sidebar/profile-rail-fleet.test.tsx index 9f867dc067..922265dcf1 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-rail-fleet.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-rail-fleet.test.tsx @@ -77,7 +77,6 @@ vi.mock('@/store/profile', async importOriginal => ({ $profileCreateRequest: atom(0), $profileOrder: atom([]), $profiles: atom([{ is_default: true, name: 'default' }]), - $profilesByConnection: atom(new Map()), $profileScope: atom('default'), ALL_PROFILES: '*', normalizeProfileKey: (name: string) => name, @@ -130,7 +129,7 @@ const connectionsRegistry = connectionsStore.$connectionsRegistry as ReturnType< typeof atom > -const { $profileOrder, $profiles, $profilesByConnection, $profileScope } = await import('@/store/profile') +const { $profileOrder, $profiles, $profileScope } = await import('@/store/profile') const profiles = $profiles as ReturnType>> const profileScope = $profileScope as ReturnType> const { _resetFleetRosterForTests } = await import('@/store/fleet-roster') @@ -216,7 +215,6 @@ beforeEach(() => { afterEach(() => { cleanup() $profileOrder.set([]) - $profilesByConnection.set(new Map()) vi.clearAllMocks() _resetFleetRosterForTests() hasMultipleConnections.set(false) @@ -231,7 +229,19 @@ describe('ProfileRail fleet mode', () => { it('keeps the custom named-profile order when a source becomes inactive', async () => { armFleet() profiles.set([...profiles.get(), { name: 'editor', is_default: false }]) - $profilesByConnection.set(new Map([['gateway-a', $profiles.get()]])) + getAgentRoster.mockResolvedValue({ + ...roster, + agents: [ + ...roster.agents, + { + connectionId: 'gateway-a', + connectionKind: 'remote', + connectionLabel: 'Gateway A', + profile: 'editor', + handle: 'editor' + } + ] + }) $profileOrder.set(['scout', 'editor']) const container = await renderFleet() @@ -250,32 +260,19 @@ describe('ProfileRail fleet mode', () => { expect(labels()).toEqual(activeOrder) }) - it('keeps each source’s known squares in place when the incoming profile read has no result', async () => { + it('never renders the outgoing source’s squares under an incoming source whose profile read failed', async () => { armFleet() - // A newly discovered outgoing profile is newer than the fleet roster. - profiles.set([...profiles.get(), { name: 'editor', is_default: false }]) - $profilesByConnection.set(new Map([['gateway-a', $profiles.get()]])) const container = await renderFleet() - const groups = () => Array.from(container.querySelectorAll('[data-slot="profile-rail-gateway"]')) - - const before = groups().map(group => [ - group.getAttribute('data-connection-id'), - group.querySelectorAll('button').length - ]) await act(async () => { activeConnectionId.set('local') profiles.set([]) // No successful REST result on the incoming source (e.g. 401). }) - expect( - groups().map(group => [group.getAttribute('data-connection-id'), group.querySelectorAll('button').length]) - ).toEqual(before) const active = container.querySelector('[data-active="true"][data-connection-id="local"]') as HTMLElement - expect(within(active).queryByRole('button', { name: /scout|editor/ })).toBeNull() - expect(within(active).getByRole('button', { name: /builder/ })).toBeTruthy() - fireEvent.click(within(active).getByRole('button', { name: /builder/ })) - expect(selectConnection).toHaveBeenCalledWith('local', { profile: 'builder' }) + expect(within(active).queryByRole('button', { name: /scout/ })).toBeNull() + // The outgoing gateway keeps its own roster squares, at rest. + expect(screen.getByRole('button', { name: 'scout · Gateway A' })).toBeTruthy() }) it('stays on the single-gateway path with one registered gateway', async () => { diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index b40fa95117..e07f5774d4 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -71,7 +71,6 @@ import { $profileCreateRequest, $profileOrder, $profiles, - $profilesByConnection, $profileScope, ALL_PROFILES, normalizeProfileKey, @@ -150,7 +149,6 @@ export function ProfileRail() { const { t } = useI18n() const p = t.profiles const profiles = useStore($profiles) - const profilesByConnection = useStore($profilesByConnection) const scope = useStore($profileScope) const gatewayProfile = useStore($activeGatewayProfile) const order = useStore($profileOrder) @@ -179,32 +177,22 @@ export function ProfileRail() { const connections = registry?.connections - const groups = useMemo( - () => - buildRestGroups({ activeConnectionId: null, connections: connections ?? [], profilesByConnection, roster }).map( - group => ({ - ...group, - named: sortByProfileOrder( - group.named.map(agent => ({ name: agent.profile, agent })), - order - ).map(({ agent }) => agent) - }) - ), - [connections, order, profilesByConnection, roster] - ) - + // At-rest named squares follow the same user order as the active strip, so + // a gateway's squares do not rearrange when it becomes inactive. const restGroups = useMemo( - () => (multipleConnections ? groups.filter(group => group.connectionId !== activeConnectionId) : []), - [activeConnectionId, groups, multipleConnections] + () => + multipleConnections + ? buildRestGroups({ activeConnectionId, connections: connections ?? [], roster }).map(group => ({ + ...group, + named: sortByProfileOrder( + group.named.map(agent => ({ name: agent.profile, agent })), + order + ).map(({ agent }) => agent) + })) + : [], + [activeConnectionId, connections, multipleConnections, order, roster] ) - // Until this source has served its first list, retain the roster's exact - // routes rather than borrowing outgoing profiles or collapsing its squares. - const activeFallback = - profiles.length === 0 && activeConnectionId && !profilesByConnection.has(activeConnectionId) - ? groups.find(group => group.connectionId === activeConnectionId) - : undefined - // Fleet mode needs something to show beside the active gateway. Two // registrations of one backend collapse to a single roster source, which // keeps the rail on its single-gateway path. @@ -246,9 +234,7 @@ export function ProfileRail() { // ahead of the wheel effect, which re-binds when the strip mounts/unmounts. // The threshold counts the whole fleet: fourteen squares are fourteen // squares wherever they live. - const condensed = - profiles.length + countRestAgents(activeFallback ? [...restGroups, activeFallback] : restGroups) > - PROFILE_DROPDOWN_THRESHOLD + const condensed = profiles.length + countRestAgents(restGroups) > PROFILE_DROPDOWN_THRESHOLD const switchToRest = (agent: FleetAgent) => { const key = fleetRouteKey(agent.connectionId, agent.profile) @@ -481,7 +467,7 @@ export function ProfileRail() { onSelect={selectProfile} onSelectRest={switchToRest} profiles={named} - restGroups={activeFallback ? [activeFallback, ...restGroups] : restGroups} + restGroups={restGroups} /> ) : ( @@ -494,21 +480,7 @@ export function ProfileRail() { strip keeps one shape whichever gateway is active. */} {fleet ? fleetSequence.map((entry, index) => - entry.kind === 'active' && activeFallback ? ( - setProfileColor(agent.profile, color)} - onRename={setPendingRestRename} - onSelect={switchToRest} - pendingRoute={pendingRoute} - /> - ) : entry.kind === 'active' ? ( + entry.kind === 'active' ? ( first: boolean group: FleetGroup - activeProfile?: null | string onDelete: (agent: FleetAgent) => void onEditSoul: (agent: FleetAgent) => void onRecolor: (agent: FleetAgent, color: null | string) => void @@ -1016,25 +986,24 @@ function FleetRestGroup({ onSelect(group.defaultAgent)} pending={pendingRoute === defaultKey} slot="profile-rail-rest-home" /> {group.named.map(agent => ( void @@ -1100,8 +1067,7 @@ function RestSquare({