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:
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user