fd565c80e9
With several gateways registered, the Sessions profile rail only ever showed the active gateway's profiles; reaching a bot on another machine meant a gateway switch first, then a click on the rail that appeared afterwards. Bot Mode (#91134) and Capabilities already read the union agent roster; the rail is now its third consumer. - Every registered gateway's profiles sit on the one strip, in registry order (This device first, then by label), each group headed by that gateway's kind glyph. The active gateway's squares are unchanged; the others are "at rest" (dimmed) with tooltips/accessible names qualified by machine (`inbox · Homelab`), so same-named profiles never read alike. - Clicking an at-rest square performs the same dial → commit → re-home as the statusbar switcher, landing on that exact (gateway, profile): `selectConnection(id, { profile })`. The spinner sits on the clicked square; the previous source stays painted until the target answers. Groups keep their slots whichever gateway is active, so a square never moves under the pointer that clicked it. - Right-click on an at-rest square: Switch to / Color / Rename / Edit SOUL.md / Delete, executed on the owning gateway (renameProfile, getProfileSoul and updateProfileSoul accept the same scope deleteProfile already had); the delete confirmation names the machine. The legacy per-profile "Connect to a remote host…" item is hidden on multi-gateway setups, where the rail shows machines directly. - Unreachable gateways keep their squares with an amber dot on the glyph; two registrations of one backend collapse to one group; past thirteen squares across the fleet the strip condenses into a menu sectioned by gateway. Roster is fetched on mount / focus / registry change only — no periodic fleet polling. - Single-gateway Desktops render exactly as before: no roster fetch, same DOM. Also fixes a boot race the e2e surfaced: initializeConnectionsRegistry() "restored" the launch-mode source over a switch the user had already made while boot was settling (same class as #91047). The restore now yields when a switch is pending or already landed. Tests: pure grouping (fleet-rail.test.ts), rail component fleet mode (profile-rail-fleet.test.tsx), store (explicit profile pick; restore yields), and a Playwright e2e (fleet-profile-rail.spec.ts) that boots Desktop with two REAL backends — the local one plus a second `hermes serve` registered as a remote URL connection — and verifies layout, a real re-home, gateway-scoped actions, and order stability. Docs: multi-connection-desktop.md describes the fleet rail. Refs #89304, #92384, #91047, #94724 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
119 lines
4.1 KiB
TypeScript
119 lines
4.1 KiB
TypeScript
import type {
|
|
ProfileCreatePayload,
|
|
ProfileDesktopOverlay,
|
|
ProfileSetupCommand,
|
|
ProfileSoul,
|
|
ProfilesResponse
|
|
} from '@/types/hermes'
|
|
|
|
import { capabilityScoped, hermesApi, type ProfileScope, STARTUP_REQUEST_TIMEOUT_MS } from './client'
|
|
|
|
export function getProfiles(): Promise<ProfilesResponse> {
|
|
return hermesApi<ProfilesResponse>({
|
|
path: '/api/profiles',
|
|
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
|
})
|
|
}
|
|
|
|
export function createProfile(body: ProfileCreatePayload): Promise<{ name: string; ok: boolean; path: string }> {
|
|
return hermesApi<{ name: string; ok: boolean; path: string }>({
|
|
path: '/api/profiles',
|
|
method: 'POST',
|
|
body
|
|
})
|
|
}
|
|
|
|
// Explicit (connection, profile) pin for a profile that lives on a gateway
|
|
// other than the foreground one — the fleet profile rail edits a remote
|
|
// square's SOUL/name in place. Same contract as deleteProfile's scope.
|
|
function profileOwnerScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } {
|
|
return {
|
|
...capabilityScoped(scope),
|
|
...(scope && typeof scope === 'object' && scope.connectionId?.trim() === 'local' ? { connectionId: 'local' } : {})
|
|
}
|
|
}
|
|
|
|
export function renameProfile(
|
|
name: string,
|
|
newName: string,
|
|
scope?: ProfileScope
|
|
): Promise<{ name: string; ok: boolean; path: string }> {
|
|
return hermesApi<{ name: string; ok: boolean; path: string }>({
|
|
...profileOwnerScoped(scope),
|
|
path: `/api/profiles/${encodeURIComponent(name)}`,
|
|
method: 'PATCH',
|
|
body: { new_name: newName }
|
|
})
|
|
}
|
|
|
|
export function deleteProfile(name: string, scope?: ProfileScope): Promise<{ ok: boolean; path: string }> {
|
|
const normalized = name.trim()
|
|
const scopedProfile = scope && typeof scope === 'object' ? scope.profile?.trim() : undefined
|
|
|
|
if (!normalized) {
|
|
return Promise.reject(new Error('Profile name required'))
|
|
}
|
|
|
|
if (normalized.toLowerCase() === 'default' || scopedProfile?.toLowerCase() === 'default') {
|
|
return Promise.reject(new Error('The default profile cannot be deleted.'))
|
|
}
|
|
|
|
return hermesApi<{ ok: boolean; path: string }>({
|
|
...profileOwnerScoped(scope),
|
|
path: `/api/profiles/${encodeURIComponent(normalized)}`,
|
|
method: 'DELETE'
|
|
})
|
|
}
|
|
|
|
export function getProfileSoul(name: string, scope?: ProfileScope): Promise<ProfileSoul> {
|
|
return hermesApi<ProfileSoul>({
|
|
...profileOwnerScoped(scope),
|
|
path: `/api/profiles/${encodeURIComponent(name)}/soul`
|
|
})
|
|
}
|
|
|
|
export function updateProfileSoul(name: string, content: string, scope?: ProfileScope): Promise<{ ok: boolean }> {
|
|
return hermesApi<{ ok: boolean }>({
|
|
...profileOwnerScoped(scope),
|
|
path: `/api/profiles/${encodeURIComponent(name)}/soul`,
|
|
method: 'PUT',
|
|
body: { content }
|
|
})
|
|
}
|
|
|
|
export function getProfileSetupCommand(name: string): Promise<ProfileSetupCommand> {
|
|
return hermesApi<ProfileSetupCommand>({
|
|
path: `/api/profiles/${encodeURIComponent(name)}/setup-command`
|
|
})
|
|
}
|
|
|
|
/** Export a profile to a shareable .tar.gz on the backend's filesystem.
|
|
* `extraFiles` stages extra root-level files (desktop.json — the appearance/
|
|
* interface overlay) into the archive alongside the profile's own artifacts. */
|
|
export function exportProfileArchive(
|
|
name: string,
|
|
opts: { extraFiles?: Record<string, string>; output?: string } = {}
|
|
): Promise<{ archive: string; ok: boolean }> {
|
|
return hermesApi<{ archive: string; ok: boolean }>({
|
|
path: `/api/profiles/${encodeURIComponent(name)}/export`,
|
|
method: 'POST',
|
|
body: { extra_files: opts.extraFiles ?? {}, output: opts.output ?? '' },
|
|
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
|
})
|
|
}
|
|
|
|
/** Import a profile .tar.gz as a new profile. Returns the bundled desktop
|
|
* appearance overlay too (when the archive carried one) so the caller can
|
|
* apply theme/layout without another round-trip. */
|
|
export function importProfileArchive(
|
|
archive: string,
|
|
name?: string
|
|
): Promise<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }> {
|
|
return hermesApi<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }>({
|
|
path: '/api/profiles/import',
|
|
method: 'POST',
|
|
body: { archive, name: name || null },
|
|
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
|
})
|
|
}
|