+ )
+}
diff --git a/components/ambient-ui/src/components/ui/popover.tsx b/components/ambient-ui/src/components/ui/popover.tsx
new file mode 100644
index 000000000..c3b0b4348
--- /dev/null
+++ b/components/ambient-ui/src/components/ui/popover.tsx
@@ -0,0 +1,48 @@
+"use client"
+
+import * as React from "react"
+import * as PopoverPrimitive from "@radix-ui/react-popover"
+
+import { cn } from "@/lib/utils"
+
+function Popover({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function PopoverTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function PopoverAnchor({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function PopoverContent({
+ className,
+ align = "end",
+ sideOffset = 6,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
diff --git a/components/ambient-ui/src/hooks/__tests__/use-connection-status.test.ts b/components/ambient-ui/src/hooks/__tests__/use-connection-status.test.ts
new file mode 100644
index 000000000..e058907f7
--- /dev/null
+++ b/components/ambient-ui/src/hooks/__tests__/use-connection-status.test.ts
@@ -0,0 +1,111 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import { renderHook, waitFor } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { createElement } from 'react'
+import { useConnectionStatus } from '../use-connection-status'
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ })
+ return function Wrapper({ children }: { children: React.ReactNode }) {
+ return createElement(QueryClientProvider, { client: queryClient }, children)
+ }
+}
+
+type FakeFetchResult = {
+ ok: boolean
+ status: number
+ json: () => Promise>
+}
+
+function installFakeFetch(result: FakeFetchResult) {
+ const original = globalThis.fetch
+ globalThis.fetch = async (input: RequestInfo | URL) => {
+ const url = typeof input === 'string' ? input : input.toString()
+ if (url === '/api/healthz') {
+ return result as Response
+ }
+ return original(input)
+ }
+ return () => {
+ globalThis.fetch = original
+ }
+}
+
+describe('useConnectionStatus', () => {
+ let cleanup: () => void
+
+ afterEach(() => {
+ cleanup?.()
+ })
+
+ it('returns "checking" as the initial status', () => {
+ cleanup = installFakeFetch({
+ ok: true,
+ status: 200,
+ json: async () => ({ status: 'ok' }),
+ })
+
+ const { result } = renderHook(() => useConnectionStatus(), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.status).toBe('checking')
+ })
+
+ it('returns "connected" when healthz returns 200', async () => {
+ cleanup = installFakeFetch({
+ ok: true,
+ status: 200,
+ json: async () => ({ status: 'ok' }),
+ })
+
+ const { result } = renderHook(() => useConnectionStatus(), {
+ wrapper: createWrapper(),
+ })
+
+ await waitFor(() => {
+ expect(result.current.status).toBe('connected')
+ })
+ expect(result.current.lastChecked).not.toBeNull()
+ })
+
+ it('returns "disconnected" when healthz returns an error', async () => {
+ cleanup = installFakeFetch({
+ ok: false,
+ status: 500,
+ json: async () => ({ status: 'error' }),
+ })
+
+ const { result } = renderHook(() => useConnectionStatus(), {
+ wrapper: createWrapper(),
+ })
+
+ await waitFor(() => {
+ expect(result.current.status).toBe('disconnected')
+ })
+ })
+
+ it('returns "disconnected" when fetch throws a network error', async () => {
+ const original = globalThis.fetch
+ globalThis.fetch = async (input: RequestInfo | URL) => {
+ const url = typeof input === 'string' ? input : input.toString()
+ if (url === '/api/healthz') {
+ throw new Error('Network error')
+ }
+ return original(input)
+ }
+ cleanup = () => {
+ globalThis.fetch = original
+ }
+
+ const { result } = renderHook(() => useConnectionStatus(), {
+ wrapper: createWrapper(),
+ })
+
+ await waitFor(() => {
+ expect(result.current.status).toBe('disconnected')
+ })
+ })
+})
diff --git a/components/ambient-ui/src/hooks/use-connection-status.ts b/components/ambient-ui/src/hooks/use-connection-status.ts
new file mode 100644
index 000000000..6cf9a3519
--- /dev/null
+++ b/components/ambient-ui/src/hooks/use-connection-status.ts
@@ -0,0 +1,37 @@
+'use client'
+
+import { useQuery } from '@tanstack/react-query'
+
+export type ConnectionStatus = 'connected' | 'disconnected' | 'checking'
+
+type UseConnectionStatusReturn = {
+ status: ConnectionStatus
+ lastChecked: string | null
+}
+
+async function checkHealth(): Promise<{ ok: boolean }> {
+ const response = await fetch('/api/healthz')
+ if (!response.ok) {
+ throw new Error(`Health check failed: ${response.status}`)
+ }
+ return { ok: true }
+}
+
+export function useConnectionStatus(): UseConnectionStatusReturn {
+ const { data, isError, isPending } = useQuery({
+ queryKey: ['connection-status'],
+ queryFn: checkHealth,
+ refetchInterval: 10_000,
+ retry: false,
+ })
+
+ if (isPending) {
+ return { status: 'checking', lastChecked: null }
+ }
+
+ if (isError || !data?.ok) {
+ return { status: 'disconnected', lastChecked: new Date().toISOString() }
+ }
+
+ return { status: 'connected', lastChecked: new Date().toISOString() }
+}
diff --git a/components/ambient-ui/src/lib/runtime-config.ts b/components/ambient-ui/src/lib/runtime-config.ts
new file mode 100644
index 000000000..83b41cc10
--- /dev/null
+++ b/components/ambient-ui/src/lib/runtime-config.ts
@@ -0,0 +1,49 @@
+import { env } from './env'
+import { getSession } from './session'
+
+export type RuntimeConfig = {
+ apiServerUrl: string
+ customToken: string | null
+ defaultApiServerUrl: string
+ isCustomContext: boolean
+}
+
+export async function getRuntimeConfig(): Promise {
+ const defaultUrl = env.API_SERVER_URL
+ try {
+ const session = await getSession()
+ const apiServerUrl = session.customApiServerUrl || defaultUrl
+ const customToken = session.customToken || null
+ return {
+ apiServerUrl,
+ customToken,
+ defaultApiServerUrl: defaultUrl,
+ isCustomContext: apiServerUrl !== defaultUrl || customToken !== null,
+ }
+ } catch {
+ return {
+ apiServerUrl: defaultUrl,
+ customToken: null,
+ defaultApiServerUrl: defaultUrl,
+ isCustomContext: false,
+ }
+ }
+}
+
+export async function setCustomContext(url?: string, token?: string | null): Promise {
+ const session = await getSession()
+ if (url) session.customApiServerUrl = url
+ if (token === null || token === '') {
+ session.customToken = undefined
+ } else if (token) {
+ session.customToken = token
+ }
+ await session.save()
+}
+
+export async function resetContext(): Promise {
+ const session = await getSession()
+ session.customApiServerUrl = undefined
+ session.customToken = undefined
+ await session.save()
+}
diff --git a/components/ambient-ui/src/lib/session.ts b/components/ambient-ui/src/lib/session.ts
index a744144e6..ae3bfc9e4 100644
--- a/components/ambient-ui/src/lib/session.ts
+++ b/components/ambient-ui/src/lib/session.ts
@@ -7,8 +7,12 @@ export type SessionData = {
accessToken: string
refreshToken: string
expiresAt: number
+ customApiServerUrl?: string
+ customToken?: string
}
+const devSessionSecret = randomBytes(32).toString("hex")
+
function getSessionOptions(): SessionOptions {
const secret = env.SESSION_SECRET
if (!secret) {
@@ -16,7 +20,7 @@ function getSessionOptions(): SessionOptions {
throw new Error("SESSION_SECRET must be set when AUTH_MODE=native-sso")
}
return {
- password: randomBytes(32).toString("hex"),
+ password: devSessionSecret,
cookieName: "ambient-ui-session",
cookieOptions: {
secure: process.env.NODE_ENV === "production",
diff --git a/specs/ambient-ui/ambient-ui.spec.md b/specs/ambient-ui/ambient-ui.spec.md
index 482f91840..ead725d9e 100644
--- a/specs/ambient-ui/ambient-ui.spec.md
+++ b/specs/ambient-ui/ambient-ui.spec.md
@@ -712,17 +712,91 @@ Every list view SHALL display a meaningful empty state when no data exists, incl
All destructive or state-changing actions (session stop/delete, credential delete/rotate, schedule enable/disable, feature flag toggle) SHALL require explicit confirmation before executing.
-### Requirement: Cluster Health Indicator
+### Requirement: Status Bar
-The top bar SHALL display a cluster connection status indicator that reflects the ambient-api-server's reachability.
+The Ambient UI SHALL display a persistent status bar fixed to the bottom of the viewport. The status bar SHALL be compact (single line) and always visible regardless of scroll position or active view.
+
+The status bar SHALL display:
+- **Connection context**: The ambient-api-server URL currently targeted by the BFF
+- **Connection status indicator**: A colored dot and label reflecting the ambient-api-server's reachability (moved from the top bar)
+
+#### Scenario: Status bar rendering
+
+- GIVEN the Ambient UI is loaded
+- WHEN any view renders
+- THEN a compact status bar is visible at the bottom of the viewport
+- AND it displays the API server URL (e.g., `https://ambient-api-server:8000`)
+- AND it displays a connection status indicator (green dot + "Connected" or red dot + "Disconnected")
+
+#### Scenario: Cluster connected
+
+- GIVEN the ambient-api-server is reachable
+- WHEN the status bar renders
+- THEN the connection indicator displays a green dot with "Connected" label
#### Scenario: Cluster disconnected
- GIVEN the ambient-api-server becomes unreachable
- WHEN the UI detects connection failure
-- THEN the cluster indicator changes to red with "Disconnected" label
+- THEN the connection indicator changes to a red dot with "Disconnected" label
- AND a pulsing animation draws attention to the status change
+### Requirement: Connection Context Switching
+
+The status bar SHALL support switching between the default SSO-authenticated connection and a custom connection with a user-provided URL and bearer token.
+
+The default connection uses the BFF's configured API server URL and the JWT from the user's SSO session (native-sso mode). A custom connection overrides both the URL and the authentication token.
+
+#### Scenario: Default SSO context
+
+- GIVEN the user has authenticated via SSO
+- WHEN no custom context is active
+- THEN the BFF proxies API requests to the configured API server URL
+- AND uses the JWT from the SSO session as the Authorization header
+- AND the status bar displays the configured URL with no override indicator
+
+#### Scenario: Enter custom context
+
+- GIVEN the status bar displays the default API server URL
+- WHEN the user clicks the URL
+- THEN the status bar expands to show two editable fields: URL and Token
+- AND the URL field is pre-populated with the current URL
+- AND the Token field is empty with placeholder text (e.g., "Bearer token")
+- AND pressing Enter on either field confirms the change
+- AND pressing Escape cancels and collapses back to the default view
+
+#### Scenario: Custom context applied
+
+- GIVEN the user enters a custom URL and token and confirms
+- WHEN the custom context is active
+- THEN the BFF proxies all API requests to the custom URL
+- AND uses the user-provided token as the Authorization header (instead of the SSO JWT)
+- AND the status bar displays the custom URL with a visual override indicator
+- AND a "Reset" control is visible to revert to the default context
+
+#### Scenario: Reset to default context
+
+- GIVEN a custom context is active
+- WHEN the user clicks the "Reset" control
+- THEN the custom URL and token are cleared
+- AND the BFF reverts to using the configured API server URL and SSO JWT
+- AND the status bar returns to its default appearance
+
+#### Scenario: Custom context with URL only (no token)
+
+- GIVEN the user enters only a custom URL without a token
+- WHEN the custom context is applied
+- THEN the BFF proxies to the custom URL
+- AND uses the SSO session JWT as the Authorization header (if available)
+- AND falls back to no Authorization header if no SSO session exists
+
+#### Scenario: Custom context persistence
+
+- GIVEN the user has set a custom context
+- WHEN the page is refreshed
+- THEN the custom context persists (stored server-side in the BFF session)
+- AND the user does not need to re-enter the URL and token
+
---
## API Dependencies