Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions apps/desktop/e2e/new-session-source-picker.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* E2E for the per-session source picker (PR #94457).
*
* The picker only mounts when more than one source is registered
* (hasMultipleConnections). The default sandbox registry has a single
* "local" connection, so this spec seeds a v2 connections.json with a
* local + remote source BEFORE launch. We then drive the two "new session"
* affordances — the sidebar row and the header "+" button — and assert that
* each opens the picker dropdown showing both registered sources.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/

import { expect, test, type Page } from '@playwright/test'
import * as fs from 'node:fs'
import * as path from 'node:path'

import {
type MockBackendFixture,
buildAppEnv,
createSandbox,
launchDesktop,
waitForAppReady,
writeEnvFile,
writeMockProviderConfig,
} from './fixtures'
import { startMockServer } from './mock-server'

/**
* A valid v2 registry with two sources (local + an unreachable remote). The
* remote URL never has to answer — we only assert the picker enumerates it.
* normalizeRegistry in electron/connection-registry.ts accepts a remote with a
* non-empty url (token optional), which keeps connections.length === 2 so
* $hasMultipleConnections is true.
*/
const TWO_SOURCE_REGISTRY = {
version: 2,
primary: 'local',
launchMode: 'primary',
lastUsed: 'local',
connections: [
{ id: 'local', kind: 'local', label: 'This device' },
{ id: 'remote-test', kind: 'remote', label: 'Test Gateway', url: 'http://127.0.0.1:59999', authMode: 'token' },
],
}

// t.settings.connections.title — the picker dropdown heading.
const REGISTERED_GATEWAYS = 'Registered gateways'
const LOCAL_LABEL = 'This device'
const REMOTE_LABEL = 'Test Gateway'

/** Seed connections.json before the app reads its v2 registry on boot. */
function seedTwoSourceRegistry(userDataDir: string): void {
fs.writeFileSync(
path.join(userDataDir, 'connections.json'),
JSON.stringify(TWO_SOURCE_REGISTRY, null, 2),
'utf8',
)
}

/** Assert the picker dropdown is open and shows both registered sources. */
async function expectPickerOpen(page: Page): Promise<void> {
const menu = page.getByRole('menu')
await expect(menu).toBeVisible({ timeout: 10_000 })
await expect(menu.getByText(REGISTERED_GATEWAYS)).toBeVisible()
await expect(page.getByRole('menuitem', { name: LOCAL_LABEL })).toBeVisible()
await expect(page.getByRole('menuitem', { name: REMOTE_LABEL })).toBeVisible()
}

test.describe('new-session source picker', () => {
test.describe.configure({ mode: 'serial' })

let fixture: MockBackendFixture

test.beforeAll(async () => {
const mock = await startMockServer()
const sandbox = createSandbox('picker')

writeMockProviderConfig(sandbox.hermesHome, mock.url)
writeEnvFile(sandbox.hermesHome)
seedTwoSourceRegistry(sandbox.userDataDir)

const env = buildAppEnv(sandbox)
const { app, page } = await launchDesktop(env)

fixture = {
app,
page,
mock,
mockUrl: mock.url,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
await mock.close()
sandbox.cleanup()
},
}

await waitForAppReady(fixture, 120_000)
})

test.afterAll(async () => {
await fixture?.cleanup()
})

test('sidebar "New session" row opens the picker with both sources', async () => {
const { page } = fixture

// Sidebar nav row (accessible name includes the ⌘N shortcut).
const sidebarNewSession = page.getByRole('button', { name: /New session ⌘ N/ })
await expect(sidebarNewSession).toBeVisible()
await sidebarNewSession.click()

await expectPickerOpen(page)
await page.keyboard.press('Escape')
await expect(page.getByRole('menu').or(page.getByRole('menuitem'))).toHaveCount(0)
})

// The header "+" new-session button (index.tsx ~1788) is also wired through
// the same NewSessionSourcePicker, but it only renders when
// showAllProfiles === false. The e2e sandbox boots in "all profiles" mode
// (multi-profile + ALL scope), so that button is absent here and can't be
// driven by this spec. Its picker wrap mirrors the sidebar row exactly, which
// this spec proves opens the dropdown, so it is covered by the shared
// component rather than a dedicated e2e case.
})
1 change: 1 addition & 0 deletions apps/desktop/electron/remote-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@ test('buildSpawnCommand is headless serve, detached, token not in argv', () => {
const cmd = buildSpawnCommand('/x/hermes', 'work', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) })
assert.match(cmd, /serve --isolated/)
assert.match(cmd, /--host 127\.0\.0\.1 --port 0/)
assert.match(cmd, /HERMES_DASHBOARD_PUBLIC_URL=http:\/\/127\.0\.0\.1/)
assert.doesNotMatch(cmd, /--skip-build|--no-open/)
assert.doesNotMatch(cmd, /\bdashboard\b/)
assert.match(cmd, /--profile/)
Expand Down
9 changes: 7 additions & 2 deletions apps/desktop/electron/remote-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ const LOCKFILE_SCHEMA_VERSION = 2
// Bumped when the desktop<->dashboard reuse contract changes in a way that makes
// an old running dashboard unsafe to reattach to (token handling, readiness/spawn
// args, served-token reconciliation). A mismatch forces a clean respawn.
const PROTOCOL_VERSION = 1
const PROTOCOL_VERSION = 2
// Isolated SSH serve inherits the remote config.yaml, including a public
// dashboard.public_url. That turns on the OAuth gate even though we bind
// 127.0.0.1, and the gate rejects ?token= on /api/ws. Override to loopback so
// 0.20.x remotes stay in token mode without waiting for a server upgrade.
const SPAWN_PUBLIC_URL = 'http://127.0.0.1'
const READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/m
const REMOTE_LOCK_DIR = '~/.hermes/desktop-ssh'
const SUPPORTED_REMOTE_OS = new Set(['Linux', 'Darwin'])
Expand Down Expand Up @@ -532,7 +537,7 @@ function buildSpawnCommand(hermesPath, profile, opts: any = {}) {

const dashCmd =
`ulimit -n ${REMOTE_NOFILE_SOFT_LIMIT} 2>/dev/null || true; ` +
`exec env HERMES_DESKTOP=1 ${hermes} ${profileArgs}${subCmd}`
`exec env HERMES_DESKTOP=1 HERMES_DASHBOARD_PUBLIC_URL=${SPAWN_PUBLIC_URL} ${hermes} ${profileArgs}${subCmd}`

return (
`mkdir -p "$(dirname ${logPath})" && ` +
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/electron/windows-remote-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ test('helper command uses the fixed remote Python entry point and quotes path da
test('Windows lock validation is scoped and exact', () => {
const lock = {
schemaVersion: 2,
protocolVersion: 1,
protocolVersion: 2,
ownershipId,
spawnNonce: '0123456789abcdef',
pid: 10,
Expand All @@ -121,7 +121,7 @@ test('Windows SSH reuse requires the requested remote profile to match the lock'

const lock = {
schemaVersion: 2,
protocolVersion: 1,
protocolVersion: 2,
ownershipId,
spawnNonce: '0123456789abcdef',
pid: 10,
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/electron/windows-remote-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import crypto from 'node:crypto'
import { assertBootstrapNotSuperseded, redactSecrets, SSH_ERROR } from './ssh-connection'

const LOCKFILE_SCHEMA_VERSION = 2
const PROTOCOL_VERSION = 1
const PROTOCOL_VERSION = 2
const READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/gm
const READY_POLL_INTERVAL_MS = 750

Expand Down
34 changes: 34 additions & 0 deletions apps/desktop/src/api/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,40 @@ export async function listSidebarSessions(req: SidebarSessionsRequest): Promise<
}
}

// Fetch a FOREIGN gateway's recent sessions for aggregation into the active
// profile's sidebar. Where listSidebarSessions rides the active backend, this
// pins { connectionId, profile } so Electron routes the REST call to that
// specific registered connection and lists ITS state.db — the same sessionScoped
// routing getSession/getSessionMessages already use. Additive by design: the
// active profile's own recents are untouched; the returned rows render in a
// dedicated "other gateways" section, each badged with its origin.
export async function listGatewayRecentSessions(
connectionId: string,
profile: string,
limit = 20,
filter: SessionSourceFilter = {}
): Promise<PaginatedSessions> {
const sourceParam = filter.source ? `&source=${encodeURIComponent(filter.source)}` : ''

const excludeParam = filter.excludeSources?.length
? `&exclude_sources=${encodeURIComponent(filter.excludeSources.join(','))}`
: ''

const result = await hermesApi<PaginatedSessions>({
...capabilityScoped({ connectionId, profile }),
path:
`/api/profiles/sessions?limit=${limit}&offset=0&min_messages=1` +
`&archived=exclude&order=recent&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}`,
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
})

return {
...result,
sessions: pageWindow(result.sessions, limit),
offset: 0
}
}

// Mutations take the owning `profile` so Electron can route them to the correct
// remote backend or local profile scope. Omit for the current/default profile.
export function setSessionArchived(id: string, archived: boolean, profile?: string | null): Promise<{ ok: boolean }> {
Expand Down
82 changes: 82 additions & 0 deletions apps/desktop/src/app/chat/connection-origin-tag.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'

import type { DesktopConnectionsRegistry, DesktopRegistryConnection } from '@/global'

const { ConnectionOriginTag, sharedSessionsOrigin, visibleSessionOrigin } = await import('./connection-origin-tag')

afterEach(cleanup)

// The tag only reads `kind` + `label`; the rest of the registry row is
// irrelevant here, so qualify a minimal fixture at the call sites.
const SSH = {
id: 'mimir',
kind: 'ssh',
label: 'mimir'
} as DesktopRegistryConnection

const LOCAL = {
id: 'local',
kind: 'local',
label: 'This device'
} as DesktopRegistryConnection

const REMOTE = {
id: 'homelab',
kind: 'remote',
label: 'Homelab'
} as DesktopRegistryConnection

const registry = {
connections: [LOCAL, SSH, REMOTE],
primary: 'local',
secureTokenStorage: true,
version: 2
} as DesktopConnectionsRegistry

describe('ConnectionOriginTag', () => {
it('labels a foreign gateway with kind icon + label', () => {
render(<ConnectionOriginTag connection={SSH} />)

const tag = screen.getByRole('img', { name: /mimir/ })
expect(tag.textContent).toContain('mimir')
expect(tag.getAttribute('data-slot')).toBe('connection-origin-tag')
expect(tag.getAttribute('data-connection-kind')).toBe('ssh')
})

it('uses the gateway kind label in its accessible name', () => {
render(<ConnectionOriginTag connection={SSH} />)

const tag = screen.getByRole('img', { name: /mimir ·/ })
// SSH is a remote gateway — never empty/blank.
expect(tag?.getAttribute('aria-label')).toMatch(/·\s*\S+/)
})
})

describe('visibleSessionOrigin', () => {
it('hides the local default', () => {
expect(visibleSessionOrigin({}, registry, 'local')).toBeNull()
})

it('names a session pinned to a foreign gateway', () => {
expect(visibleSessionOrigin({ connection_id: 'mimir' }, registry, 'local')).toEqual(SSH)
})

it('prefers the section origin for a foreign list', () => {
expect(visibleSessionOrigin({}, registry, 'local', REMOTE)).toEqual(REMOTE)
})
})

describe('sharedSessionsOrigin', () => {
it('returns the shared foreign origin', () => {
expect(
sharedSessionsOrigin([{ connection_id: 'mimir' }, { connection_id: 'mimir' }], registry, 'local')
).toEqual(SSH)
})

it('returns null when the group is mixed', () => {
expect(
sharedSessionsOrigin([{ connection_id: 'mimir' }, { connection_id: 'homelab' }], registry, 'local')
).toBeNull()
})
})
Loading