fix(desktop): at-rest rails read the roster only; active gateway keeps its single render path

The renderer's per-connection list no longer feeds buildRestGroups: with no
roster reconciler it could outlive a profile deleted elsewhere. The active
gateway is never rendered through FleetRestGroup — that path routed its own
squares through selectConnection (full dial + wipe) instead of selectProfile's
live swap. What survives from the original change is the order parity: at-rest
named squares follow $profileOrder like the active strip.
This commit is contained in:
kshitijk4poor
2026-09-15 15:46:55 +05:30
committed by kshitij
parent 256edfc1f5
commit b591df7c42
4 changed files with 39 additions and 89 deletions

View File

@@ -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<string, ProfileInfo[]>
}): 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))

View File

@@ -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,

View File

@@ -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<DesktopConnectionsRegistry | null>
>
const { $profileOrder, $profiles, $profilesByConnection, $profileScope } = await import('@/store/profile')
const { $profileOrder, $profiles, $profileScope } = await import('@/store/profile')
const profiles = $profiles as ReturnType<typeof atom<Array<{ is_default: boolean; name: string }>>>
const profileScope = $profileScope as ReturnType<typeof atom<string>>
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 () => {

View File

@@ -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}
/>
</div>
) : (
@@ -494,21 +480,7 @@ export function ProfileRail() {
strip keeps one shape whichever gateway is active. */}
{fleet
? fleetSequence.map((entry, index) =>
entry.kind === 'active' && activeFallback ? (
<FleetRestGroup
activeProfile={isAll ? null : activeKey}
colors={colors}
first={index === 0}
group={activeFallback}
key={activeFallback.connectionId}
onDelete={setPendingRestDelete}
onEditSoul={setPendingRestSoul}
onRecolor={(agent, color) => setProfileColor(agent.profile, color)}
onRename={setPendingRestRename}
onSelect={switchToRest}
pendingRoute={pendingRoute}
/>
) : entry.kind === 'active' ? (
entry.kind === 'active' ? (
<Fragment key="active">
<FleetDivider
connection={activeConnection}
@@ -983,7 +955,6 @@ function FleetDivider({
// yours), then its home square and named squares, dimmed. Clicking any of
// them re-homes onto that exact (gateway, profile).
function FleetRestGroup({
activeProfile,
colors,
first,
group,
@@ -997,7 +968,6 @@ function FleetRestGroup({
colors: Record<string, string>
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({
<span
aria-label={p.fleet.gateway(group.label)}
className="flex shrink-0 items-center gap-1"
data-active={activeProfile !== undefined}
data-active="false"
data-connection-id={group.connectionId}
data-reachable={group.reachable}
data-slot="profile-rail-gateway"
role="group"
>
<ProfilePill
active={activeProfile === group.defaultAgent.profile}
active={false}
connectionId={group.connectionId}
glyph="home"
label={p.fleet.onGateway(group.defaultAgent.profile, group.label)}
muted={activeProfile === undefined}
muted
onSelect={() => onSelect(group.defaultAgent)}
pending={pendingRoute === defaultKey}
slot="profile-rail-rest-home"
/>
{group.named.map(agent => (
<RestSquare
active={activeProfile === agent.profile}
agent={agent}
color={resolveProfileColor(agent.profile, colors)}
key={agent.profile}
@@ -1057,7 +1026,6 @@ function FleetRestGroup({
// different machines never read alike; the right-click actions run against
// the square's owning gateway.
function RestSquare({
active,
agent,
color,
onDelete,
@@ -1067,7 +1035,6 @@ function RestSquare({
onSelect,
pending
}: {
active: boolean
agent: FleetAgent
color: null | string
onDelete: () => void
@@ -1100,8 +1067,7 @@ function RestSquare({
<button
aria-busy={pending || undefined}
aria-label={label}
aria-pressed={active}
className="aria-pressed:opacity-100 relative grid size-5 shrink-0 select-none place-items-center rounded-[3px] text-[0.5625rem] font-semibold uppercase leading-none opacity-35 transition-opacity hover:opacity-100 aria-busy:opacity-100"
className="relative grid size-5 shrink-0 select-none place-items-center rounded-[3px] text-[0.5625rem] font-semibold uppercase leading-none opacity-35 transition-opacity hover:opacity-100 aria-busy:opacity-100"
data-connection-id={agent.connectionId}
data-profile={agent.profile}
data-slot="profile-rail-rest-square"