diff --git a/.github/workflows/components-build-deploy.yml b/.github/workflows/components-build-deploy.yml index d466290d4..66257c54e 100755 --- a/.github/workflows/components-build-deploy.yml +++ b/.github/workflows/components-build-deploy.yml @@ -14,6 +14,7 @@ on: - 'components/ambient-api-server/**' - 'components/ambient-control-plane/**' - 'components/ambient-mcp/**' + - 'components/ambient-ui/**' pull_request: branches: [main, alpha] paths: @@ -27,10 +28,11 @@ on: - 'components/ambient-api-server/**' - 'components/ambient-control-plane/**' - 'components/ambient-mcp/**' + - 'components/ambient-ui/**' workflow_dispatch: inputs: components: - description: 'Components to build (comma-separated: frontend,backend,operator,ambient-runner,state-sync,public-api,ambient-api-server,ambient-control-plane,ambient-mcp) - leave empty for all' + description: 'Components to build (comma-separated: frontend,backend,operator,ambient-runner,state-sync,public-api,ambient-api-server,ambient-control-plane,ambient-mcp,ambient-ui) - leave empty for all' required: false type: string default: '' @@ -60,7 +62,8 @@ jobs: {"name":"public-api","context":"./components/public-api","image":"quay.io/ambient_code/vteam_public_api","dockerfile":"./components/public-api/Dockerfile"}, {"name":"ambient-api-server","context":"./components/ambient-api-server","image":"quay.io/ambient_code/vteam_api_server","dockerfile":"./components/ambient-api-server/Dockerfile"}, {"name":"ambient-control-plane","context":"./components","image":"quay.io/ambient_code/vteam_control_plane","dockerfile":"./components/ambient-control-plane/Dockerfile"}, - {"name":"ambient-mcp","context":"./components/ambient-mcp","image":"quay.io/ambient_code/vteam_mcp","dockerfile":"./components/ambient-mcp/Dockerfile"} + {"name":"ambient-mcp","context":"./components/ambient-mcp","image":"quay.io/ambient_code/vteam_mcp","dockerfile":"./components/ambient-mcp/Dockerfile"}, + {"name":"ambient-ui","context":"./components","image":"quay.io/ambient_code/vteam_ambient_ui","dockerfile":"./components/ambient-ui/Dockerfile"} ]' SELECTED="${{ github.event.inputs.components }}" @@ -384,6 +387,7 @@ jobs: kustomize edit set image quay.io/ambient_code/vteam_public_api:latest=quay.io/ambient_code/vteam_public_api:${{ github.sha }} kustomize edit set image quay.io/ambient_code/vteam_control_plane:latest=quay.io/ambient_code/vteam_control_plane:${{ github.sha }} kustomize edit set image quay.io/ambient_code/vteam_mcp:latest=quay.io/ambient_code/vteam_mcp:${{ github.sha }} + kustomize edit set image quay.io/ambient_code/vteam_ambient_ui:latest=quay.io/ambient_code/vteam_ambient_ui:${{ github.sha }} - name: Validate kustomization working-directory: components/manifests/overlays/production @@ -462,6 +466,7 @@ jobs: kustomize edit set image quay.io/ambient_code/vteam_public_api:latest=quay.io/ambient_code/vteam_public_api:${{ github.sha }} kustomize edit set image quay.io/ambient_code/vteam_control_plane:latest=quay.io/ambient_code/vteam_control_plane:${{ github.sha }} kustomize edit set image quay.io/ambient_code/vteam_mcp:latest=quay.io/ambient_code/vteam_mcp:${{ github.sha }} + kustomize edit set image quay.io/ambient_code/vteam_ambient_ui:latest=quay.io/ambient_code/vteam_ambient_ui:${{ github.sha }} - name: Validate kustomization working-directory: components/manifests/overlays/production diff --git a/components/ambient-ui/Dockerfile b/components/ambient-ui/Dockerfile index f7bf6a457..6ce0f9974 100644 --- a/components/ambient-ui/Dockerfile +++ b/components/ambient-ui/Dockerfile @@ -5,8 +5,10 @@ WORKDIR /app USER 0 -# Copy SDK dependency first (it's a file: dependency in package.json) -COPY ambient-sdk/ts-sdk ./ambient-sdk/ts-sdk +# Copy and build SDK dependency. package.json references file:../ambient-sdk/ts-sdk +# which resolves to /ambient-sdk/ts-sdk from WORKDIR /app. +COPY ambient-sdk/ts-sdk /ambient-sdk/ts-sdk +RUN cd /ambient-sdk/ts-sdk && npm install --ignore-scripts && npm run build # Copy ambient-ui package files COPY ambient-ui/package.json ambient-ui/package-lock.json* ./ @@ -21,9 +23,9 @@ USER 0 WORKDIR /app -# Copy node_modules from deps stage +# Copy node_modules and SDK from deps stage COPY --from=deps /app/node_modules ./node_modules -COPY --from=deps /app/ambient-sdk ./ambient-sdk +COPY --from=deps /ambient-sdk /ambient-sdk COPY ambient-ui/ . # Next.js collects completely anonymous telemetry data about general usage. diff --git a/components/ambient-ui/next.config.js b/components/ambient-ui/next.config.js index 79e0b0c9e..33ba5d180 100644 --- a/components/ambient-ui/next.config.js +++ b/components/ambient-ui/next.config.js @@ -1,6 +1,47 @@ // eslint-disable-next-line @typescript-eslint/no-require-imports const path = require('path') +const DEFAULT_PATTERNS = 'localhost:*,127.0.0.1:*' + +/** + * Convert a preview-host glob pattern to CSP frame-src source(s). + * + * CSP requires a scheme for host:port patterns, so `localhost:*` becomes + * `http://localhost:* https://localhost:*`. Subdomain wildcards like + * `*.example.com` are valid CSP syntax and pass through unchanged. + */ +function toFrameSrcEntries(pattern) { + if (pattern.includes('://')) { + return [pattern] + } + // CSP only supports a wildcard as the leftmost label (e.g. *.example.com). + // Mid-domain wildcards like *.apps.rosa.*.openshiftapps.com are invalid CSP. + // Collapse to a valid prefix wildcard by keeping everything after the last *. + // e.g. *.apps.rosa.*.openshiftapps.com → *.openshiftapps.com + let cspPattern = pattern + const midWildcard = /\*\.[^*]+\*\./ + if (midWildcard.test(pattern)) { + const lastWildIdx = pattern.lastIndexOf('*.') + cspPattern = '*.' + pattern.slice(lastWildIdx + 2) + } + return [`http://${cspPattern}`, `https://${cspPattern}`] +} + +/** + * Build the CSP frame-src directive from NEXT_PUBLIC_PREVIEW_ALLOWED_HOSTS. + */ +function buildFrameSrc() { + const raw = process.env.NEXT_PUBLIC_PREVIEW_ALLOWED_HOSTS + const source = (raw && raw.trim()) || DEFAULT_PATTERNS + const patterns = source + .split(',') + .map((p) => p.trim()) + .filter((p) => p.length > 0) + + const entries = patterns.flatMap(toFrameSrcEntries) + return ["'self'", ...entries].join(' ') +} + /** @type {import('next').NextConfig} */ const nextConfig = { output: 'standalone', @@ -9,6 +50,19 @@ const nextConfig = { experimental: { staticGenerationMinPagesPerWorker: 100, }, + async headers() { + return [ + { + source: '/(.*)', + headers: [ + { + key: 'Content-Security-Policy', + value: `frame-src ${buildFrameSrc()};`, + }, + ], + }, + ] + }, } module.exports = nextConfig diff --git a/components/ambient-ui/package.json b/components/ambient-ui/package.json index 2ca957a1f..b6b9bc07a 100644 --- a/components/ambient-ui/package.json +++ b/components/ambient-ui/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev --webpack --port 3001", - "build": "next build", + "build": "next build --webpack", "start": "next start", "lint": "eslint", "test": "vitest run", diff --git a/components/ambient-ui/public/preview-bridge.js b/components/ambient-ui/public/preview-bridge.js new file mode 100644 index 000000000..df78ff554 --- /dev/null +++ b/components/ambient-ui/public/preview-bridge.js @@ -0,0 +1,86 @@ +/** + * Ambient UI Preview Bridge + * + * Include this script in pages rendered inside the Ambient UI preview iframe + * to enable cross-origin element capture and hover highlighting. + * + * Usage: + * + * The bridge listens for postMessage requests from the parent frame and + * responds with element information at the requested coordinates. + */ +(function () { + 'use strict'; + + var currentHighlight = null; + + function getClassName(el) { + // SVG elements have SVGAnimatedString for className, not a plain string + return el.getAttribute('class') || null; + } + + // Element capture: parent asks for the element at (x, y) + window.addEventListener('message', function (e) { + if (!e.data || e.data.type !== 'ambient-capture') return; + if (!e.origin || e.origin === 'null') return; + + var x = e.data.x; + var y = e.data.y; + var el = document.elementFromPoint(x, y); + + if (!el) { + e.source.postMessage({ type: 'ambient-captured', html: null, rect: null }, e.origin); + return; + } + + var rect = el.getBoundingClientRect(); + e.source.postMessage({ + type: 'ambient-captured', + html: el.outerHTML.slice(0, 500), + tagName: el.tagName.toLowerCase(), + id: el.id || null, + className: getClassName(el), + textContent: (el.textContent || '').slice(0, 100), + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + }, e.origin); + }); + + // Hover highlight: parent sends cursor position, bridge outlines the element + window.addEventListener('message', function (e) { + if (!e.data || e.data.type !== 'ambient-hover') return; + if (!e.origin || e.origin === 'null') return; + + var el = document.elementFromPoint(e.data.x, e.data.y); + + // Remove previous highlight + if (currentHighlight) { + currentHighlight.style.outline = currentHighlight._ambientSavedOutline || ''; + delete currentHighlight._ambientSavedOutline; + currentHighlight = null; + } + + if (el && el !== document.documentElement && el !== document.body) { + el._ambientSavedOutline = el.style.outline; + el.style.outline = '2px solid #4394e5'; + currentHighlight = el; + } + + if (el) { + var rect = el.getBoundingClientRect(); + e.source.postMessage({ + type: 'ambient-hovered', + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + }, e.origin); + } + }); + + // Clear hover: remove any active highlight + window.addEventListener('message', function (e) { + if (!e.data || e.data.type !== 'ambient-hover-clear') return; + if (currentHighlight) { + currentHighlight.style.outline = currentHighlight._ambientSavedOutline || ''; + delete currentHighlight._ambientSavedOutline; + currentHighlight = null; + } + }); +})(); diff --git a/components/ambient-ui/src/adapters/__tests__/session-messages.test.ts b/components/ambient-ui/src/adapters/__tests__/session-messages.test.ts new file mode 100644 index 000000000..6a7db2bb8 --- /dev/null +++ b/components/ambient-ui/src/adapters/__tests__/session-messages.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect } from 'vitest' +import type { SessionMessagesPort } from '@/ports/session-messages' +import type { FetchFn } from '../session-messages' +import { createSessionMessagesAdapterWithFetch } from '../session-messages' + +type SdkMessageResponse = { + id: string + kind: string + href: string + created_at: string | null + updated_at: string | null + session_id: string + event_type: string + payload: string + seq: number +} + +function makeSdkMessage(overrides: Partial = {}): SdkMessageResponse { + return { + id: 'msg-001', + kind: 'SessionMessage', + href: '/api/ambient/v1/sessions/sess-001/messages/msg-001', + created_at: '2026-01-15T10:00:00Z', + updated_at: '2026-01-15T10:00:00Z', + session_id: 'sess-001', + event_type: 'ui.feedback', + payload: '{"type":"approve"}', + seq: 1, + ...overrides, + } +} + +type CapturedRequest = { + url: string + method: string + headers: Record + body: unknown +} + +function createFakeFetch(options: { + response?: unknown + status?: number + captured?: CapturedRequest[] +}): FetchFn { + const captured = options.captured ?? [] + + return async (input: string, init?: RequestInit): Promise => { + captured.push({ + url: input, + method: init?.method ?? 'GET', + headers: Object.fromEntries( + Object.entries(init?.headers ?? {}), + ) as Record, + body: init?.body ? JSON.parse(init.body as string) : undefined, + }) + + const status = options.status ?? 200 + const body = JSON.stringify(options.response ?? {}) + + return { + ok: status >= 200 && status < 300, + status, + json: async () => JSON.parse(body), + } as Response + } +} + +describe('session-messages adapter', () => { + describe('send()', () => { + it('sends a POST to the correct BFF endpoint', async () => { + const captured: CapturedRequest[] = [] + const responseMessage = makeSdkMessage() + const fakeFetch = createFakeFetch({ response: responseMessage, captured }) + const adapter: SessionMessagesPort = createSessionMessagesAdapterWithFetch(fakeFetch) + + await adapter.send('sess-001', { eventType: 'ui.feedback', payload: '{"type":"approve"}' }) + + expect(captured).toHaveLength(1) + expect(captured[0].url).toBe('/api/ambient/v1/sessions/sess-001/messages') + expect(captured[0].method).toBe('POST') + expect(captured[0].headers['Content-Type']).toBe('application/json') + }) + + it('sends the correct request body with snake_case keys', async () => { + const captured: CapturedRequest[] = [] + const responseMessage = makeSdkMessage() + const fakeFetch = createFakeFetch({ response: responseMessage, captured }) + const adapter: SessionMessagesPort = createSessionMessagesAdapterWithFetch(fakeFetch) + + await adapter.send('sess-001', { eventType: 'ui.feedback', payload: '{"type":"approve"}' }) + + expect(captured[0].body).toEqual({ + event_type: 'ui.feedback', + payload: '{"type":"approve"}', + }) + }) + + it('returns a mapped domain session message', async () => { + const responseMessage = makeSdkMessage({ + id: 'msg-abc', + session_id: 'sess-xyz', + event_type: 'ui.preview', + payload: '{"url":"http://example.com"}', + seq: 5, + created_at: '2026-05-28T12:00:00Z', + }) + const fakeFetch = createFakeFetch({ response: responseMessage }) + const adapter: SessionMessagesPort = createSessionMessagesAdapterWithFetch(fakeFetch) + + const result = await adapter.send('sess-xyz', { + eventType: 'ui.preview', + payload: '{"url":"http://example.com"}', + }) + + expect(result.id).toBe('msg-abc') + expect(result.sessionId).toBe('sess-xyz') + expect(result.eventType).toBe('ui.preview') + expect(result.payload).toBe('{"url":"http://example.com"}') + expect(result.seq).toBe(5) + expect(result.createdAt).toBe('2026-05-28T12:00:00Z') + }) + + it('throws on non-OK response', async () => { + const fakeFetch = createFakeFetch({ status: 500 }) + const adapter: SessionMessagesPort = createSessionMessagesAdapterWithFetch(fakeFetch) + + await expect( + adapter.send('sess-001', { eventType: 'ui.feedback', payload: '{}' }), + ).rejects.toThrow('Failed to send session message: 500') + }) + + it('encodes special characters in session ID', async () => { + const captured: CapturedRequest[] = [] + const responseMessage = makeSdkMessage() + const fakeFetch = createFakeFetch({ response: responseMessage, captured }) + const adapter: SessionMessagesPort = createSessionMessagesAdapterWithFetch(fakeFetch) + + await adapter.send('sess/special&id', { eventType: 'test', payload: '{}' }) + + expect(captured[0].url).toBe( + '/api/ambient/v1/sessions/sess%2Fspecial%26id/messages', + ) + }) + }) + + describe('list()', () => { + it('sends a GET to the correct BFF endpoint', async () => { + const captured: CapturedRequest[] = [] + const listResponse = { + kind: 'SessionMessageList', + page: 1, + size: 20, + total: 0, + items: [], + } + const fakeFetch = createFakeFetch({ response: listResponse, captured }) + const adapter: SessionMessagesPort = createSessionMessagesAdapterWithFetch(fakeFetch) + + await adapter.list('sess-001') + + expect(captured).toHaveLength(1) + expect(captured[0].url).toBe('/api/ambient/v1/sessions/sess-001/messages') + expect(captured[0].method).toBe('GET') + }) + + it('returns paginated domain messages', async () => { + const messages = [ + makeSdkMessage({ id: 'msg-001', seq: 1 }), + makeSdkMessage({ id: 'msg-002', seq: 2 }), + ] + const listResponse = { + kind: 'SessionMessageList', + page: 1, + size: 20, + total: 50, + items: messages, + } + const fakeFetch = createFakeFetch({ response: listResponse }) + const adapter: SessionMessagesPort = createSessionMessagesAdapterWithFetch(fakeFetch) + + const result = await adapter.list('sess-001') + + expect(result.items).toHaveLength(2) + expect(result.items[0].id).toBe('msg-001') + expect(result.items[0].seq).toBe(1) + expect(result.items[1].id).toBe('msg-002') + expect(result.items[1].seq).toBe(2) + expect(result.total).toBe(50) + expect(result.page).toBe(1) + expect(result.size).toBe(20) + expect(result.hasMore).toBe(true) + }) + + it('returns hasMore=false when all items fit', async () => { + const listResponse = { + kind: 'SessionMessageList', + page: 1, + size: 20, + total: 2, + items: [ + makeSdkMessage({ id: 'msg-001' }), + makeSdkMessage({ id: 'msg-002' }), + ], + } + const fakeFetch = createFakeFetch({ response: listResponse }) + const adapter: SessionMessagesPort = createSessionMessagesAdapterWithFetch(fakeFetch) + + const result = await adapter.list('sess-001') + + expect(result.hasMore).toBe(false) + }) + + it('passes pagination params as query string', async () => { + const captured: CapturedRequest[] = [] + const listResponse = { + kind: 'SessionMessageList', + page: 2, + size: 10, + total: 25, + items: [], + } + const fakeFetch = createFakeFetch({ response: listResponse, captured }) + const adapter: SessionMessagesPort = createSessionMessagesAdapterWithFetch(fakeFetch) + + await adapter.list('sess-001', { page: 2, size: 10 }) + + expect(captured[0].url).toBe( + '/api/ambient/v1/sessions/sess-001/messages?page=2&size=10', + ) + }) + + it('throws on non-OK response', async () => { + const fakeFetch = createFakeFetch({ status: 404 }) + const adapter: SessionMessagesPort = createSessionMessagesAdapterWithFetch(fakeFetch) + + await expect(adapter.list('sess-001')).rejects.toThrow( + 'Failed to list session messages: 404', + ) + }) + + it('maps SDK messages to domain messages', async () => { + const listResponse = { + kind: 'SessionMessageList', + page: 1, + size: 20, + total: 1, + items: [ + makeSdkMessage({ + id: 'msg-mapped', + session_id: 'sess-mapped', + event_type: 'ui.feedback.response', + payload: '{"decision":"reject","reason":"too complex"}', + seq: 42, + created_at: '2026-05-28T15:30:00Z', + }), + ], + } + const fakeFetch = createFakeFetch({ response: listResponse }) + const adapter: SessionMessagesPort = createSessionMessagesAdapterWithFetch(fakeFetch) + + const result = await adapter.list('sess-mapped') + const msg = result.items[0] + + expect(msg.id).toBe('msg-mapped') + expect(msg.sessionId).toBe('sess-mapped') + expect(msg.eventType).toBe('ui.feedback.response') + expect(msg.payload).toBe('{"decision":"reject","reason":"too complex"}') + expect(msg.seq).toBe(42) + expect(msg.createdAt).toBe('2026-05-28T15:30:00Z') + }) + + it('handles null created_at by mapping to empty string', async () => { + const listResponse = { + kind: 'SessionMessageList', + page: 1, + size: 20, + total: 1, + items: [ + makeSdkMessage({ created_at: null }), + ], + } + const fakeFetch = createFakeFetch({ response: listResponse }) + const adapter: SessionMessagesPort = createSessionMessagesAdapterWithFetch(fakeFetch) + + const result = await adapter.list('sess-001') + + expect(result.items[0].createdAt).toBe('') + }) + }) +}) diff --git a/components/ambient-ui/src/adapters/index.ts b/components/ambient-ui/src/adapters/index.ts index b55c4f1e2..4a880e4df 100644 --- a/components/ambient-ui/src/adapters/index.ts +++ b/components/ambient-ui/src/adapters/index.ts @@ -1,4 +1,4 @@ export { getSessionAPI, getProjectAPI, getConfig } from './sdk-client' -export { mapSdkSessionToDomain, mapSdkProjectToDomain } from './mappers' export { createSessionsAdapter } from './sdk-sessions' export { createProjectsAdapter } from './sdk-projects' +export { createSessionMessagesAdapterWithFetch } from './session-messages' diff --git a/components/ambient-ui/src/adapters/mappers.ts b/components/ambient-ui/src/adapters/mappers.ts index 5b6f290d1..318c11cd0 100644 --- a/components/ambient-ui/src/adapters/mappers.ts +++ b/components/ambient-ui/src/adapters/mappers.ts @@ -1,5 +1,5 @@ import type { Session, Project } from 'ambient-sdk' -import type { DomainSession, DomainProject, SessionPhase } from '@/domain/types' +import type { DomainSession, DomainProject, DomainSessionMessage, SessionPhase } from '@/domain/types' const VALID_PHASES: ReadonlySet = new Set([ 'Pending', @@ -69,3 +69,23 @@ export function mapSdkProjectToDomain(sdk: Project): DomainProject { updatedAt: sdk.updated_at ?? '', } } + +export type SdkSessionMessageShape = { + id: string + session_id: string + event_type: string + payload: string + seq: number + created_at: string | null +} + +export function mapSessionMessageToDomain(sdk: SdkSessionMessageShape): DomainSessionMessage { + return { + id: sdk.id, + sessionId: sdk.session_id, + eventType: sdk.event_type, + payload: sdk.payload, + seq: sdk.seq, + createdAt: sdk.created_at ?? '', + } +} diff --git a/components/ambient-ui/src/adapters/session-messages.ts b/components/ambient-ui/src/adapters/session-messages.ts new file mode 100644 index 000000000..edda74b24 --- /dev/null +++ b/components/ambient-ui/src/adapters/session-messages.ts @@ -0,0 +1,86 @@ +import type { SessionMessagesPort } from '@/ports/session-messages' +import type { DomainSessionMessage, ListParams, PaginatedResult } from '@/domain/types' +import { mapSessionMessageToDomain } from './mappers' +import type { SdkSessionMessageShape } from './mappers' + +type SessionMessageResponse = SdkSessionMessageShape & { + kind: string + href: string + updated_at: string | null +} + +type SessionMessageListResponse = { + kind: string + page: number + size: number + total: number + items: SessionMessageResponse[] +} + +export type FetchFn = (input: string, init?: RequestInit) => Promise + +function sanitizeSessionId(value: string): string { + return encodeURIComponent(value) +} + +function buildQueryString(params?: ListParams): string { + const parts: string[] = [] + if (params?.page) parts.push(`page=${params.page}`) + if (params?.size) parts.push(`size=${params.size}`) + if (params?.search) parts.push(`search=${encodeURIComponent(params.search)}`) + if (params?.orderBy) parts.push(`orderBy=${encodeURIComponent(params.orderBy)}`) + return parts.length > 0 ? `?${parts.join('&')}` : '' +} + +function createSessionMessagesAdapter(fetchFn: FetchFn): SessionMessagesPort { + return { + async send( + sessionId: string, + message: { eventType: string; payload: string }, + ): Promise { + const url = `/api/ambient/v1/sessions/${sanitizeSessionId(sessionId)}/messages` + const response = await fetchFn(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + event_type: message.eventType, + payload: message.payload, + }), + }) + if (!response.ok) { + throw new Error(`Failed to send session message: ${response.status}`) + } + const data = (await response.json()) as SessionMessageResponse + return mapSessionMessageToDomain(data) + }, + + async list( + sessionId: string, + params?: ListParams, + ): Promise> { + const qs = buildQueryString(params) + const url = `/api/ambient/v1/sessions/${sanitizeSessionId(sessionId)}/messages${qs}` + const response = await fetchFn(url, { + method: 'GET', + }) + if (!response.ok) { + throw new Error(`Failed to list session messages: ${response.status}`) + } + const data = (await response.json()) as SessionMessageListResponse + const items = data.items.map(mapSessionMessageToDomain) + const page = params?.page ?? 1 + const size = params?.size ?? 20 + return { + items, + total: data.total, + page, + size, + hasMore: page * size < data.total, + } + }, + } +} + +export function createSessionMessagesAdapterWithFetch(fetchFn?: FetchFn): SessionMessagesPort { + return createSessionMessagesAdapter(fetchFn ?? globalThis.fetch.bind(globalThis)) +} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/session-header.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/session-header.tsx index ef95018e6..f410a4def 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/session-header.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/session-header.tsx @@ -1,30 +1,107 @@ +'use client' + +import { useState } from 'react' +import { ExternalLink, Square, RotateCcw } from 'lucide-react' import type { DomainSession } from '@/domain/types' +import { getPreviewAnnotations } from '@/domain/annotations' +import { useStopSession, useStartSession } from '@/queries/use-sessions' +import { useSendFeedback } from '@/queries/use-send-feedback' import { PhaseBadge } from '../../_components/phase-badge' import { formatDuration, formatRelativeTime } from '@/lib/format-timestamp' +import { Button } from '@/components/ui/button' +import { PreviewOverlay } from '@/components/preview/preview-overlay' + +const STOPPABLE_PHASES = new Set(['Running', 'Pending', 'Creating']) +const RESTARTABLE_PHASES = new Set(['Completed', 'Failed', 'Stopped']) export function SessionHeader({ session }: { session: DomainSession }) { + const [previewOpen, setPreviewOpen] = useState(false) + const stopSession = useStopSession() + const startSession = useStartSession() + const sendFeedback = useSendFeedback() + + const preview = getPreviewAnnotations(session.annotations) + const canStop = STOPPABLE_PHASES.has(session.phase) + const canRestart = RESTARTABLE_PHASES.has(session.phase) + return ( -
-
-

{session.name}

- -
-
- {session.agentName && ( - - )} - {session.model && ( - - )} - {session.startTime && ( - - )} - + <> +
+
+
+

{session.name}

+ +
+ +
+ {preview && ( + + )} + + {canStop && ( + + )} + + {canRestart && ( + + )} +
+
+ +
+ {session.agentName && ( + + )} + {session.model && ( + + )} + {session.startTime && ( + + )} + +
-
+ + {previewOpen && preview && ( + setPreviewOpen(false)} + onSendFeedback={(batch) => sendFeedback.mutate(batch)} + /> + )} + ) } diff --git a/components/ambient-ui/src/app/api/ambient/v1/[...path]/route.ts b/components/ambient-ui/src/app/api/ambient/v1/[...path]/route.ts index 3939cbeae..cdb684165 100644 --- a/components/ambient-ui/src/app/api/ambient/v1/[...path]/route.ts +++ b/components/ambient-ui/src/app/api/ambient/v1/[...path]/route.ts @@ -15,8 +15,7 @@ async function proxyRequest( return Response.json({ error: "invalid_path" }, { status: 400 }) } const pathStr = path.map(s => encodeURIComponent(s)).join("/") - // SDK uses /api/ambient/v1, server uses /api/adp/v1 - const url = new URL(`/api/adp/v1/${pathStr}`, API_SERVER_URL) + const url = new URL(`/api/ambient/v1/${pathStr}`, API_SERVER_URL) url.search = new URL(request.url).search const accessToken = await resolveAccessToken(request) diff --git a/components/ambient-ui/src/app/api/preview-proxy/route.ts b/components/ambient-ui/src/app/api/preview-proxy/route.ts new file mode 100644 index 000000000..7832f3e8a --- /dev/null +++ b/components/ambient-ui/src/app/api/preview-proxy/route.ts @@ -0,0 +1,130 @@ +import { resolveAccessToken } from "@/lib/auth" +import { + validatePreviewUrl, + stripFrameBlockingHeaders, + injectBaseTag, + buildBaseHref, +} from "@/lib/preview-proxy" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +const MAX_RESPONSE_BYTES = 10 * 1024 * 1024 // 10 MB + +export async function GET(request: Request): Promise { + const url = new URL(request.url).searchParams.get("url") + if (!url) { + return Response.json( + { error: "Missing 'url' query parameter" }, + { status: 400 }, + ) + } + + const validation = validatePreviewUrl(url) + if (!validation.valid) { + return Response.json({ error: validation.reason }, { status: 403 }) + } + + const accessToken = await resolveAccessToken(request) + if (!accessToken) { + return Response.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedUrl = validation.parsed + + // SSRF guard: reconstruct the URL from validated components so the fetch + // target is not tainted by the raw user input (satisfies CodeQL SSRF check). + const safeUrl = new URL(parsedUrl.pathname + parsedUrl.search, parsedUrl.origin) + const targetUrl = safeUrl.href + + let upstream: Response + try { + upstream = await fetch(targetUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + }, + redirect: "manual", + signal: AbortSignal.timeout(15_000), + }) + } catch (error: unknown) { + if (error instanceof Error && error.name === "AbortError") { + console.error("[Preview proxy] request timed out:", parsedUrl.origin) + return Response.json( + { error: "Failed to reach preview target" }, + { status: 502 }, + ) + } + console.error( + "[Preview proxy] fetch failed:", + error instanceof Error ? error.message : error, + ) + return Response.json( + { error: "Failed to reach preview target" }, + { status: 502 }, + ) + } + + // Handle redirects by rewriting the Location header to go through the proxy + if (upstream.status >= 300 && upstream.status < 400) { + const location = upstream.headers.get("location") + if (location) { + const absoluteLocation = new URL(location, parsedUrl).href + const proxyLocation = `/api/preview-proxy?url=${encodeURIComponent(absoluteLocation)}` + return new Response(null, { + status: upstream.status, + headers: { Location: proxyLocation }, + }) + } + } + + // Check Content-Length if provided + const contentLength = upstream.headers.get("content-length") + if (contentLength && Number(contentLength) > MAX_RESPONSE_BYTES) { + return Response.json({ error: "Response too large" }, { status: 413 }) + } + + const cleaned = stripFrameBlockingHeaders(upstream.headers) + + const responseHeaders = new Headers(cleaned) + responseHeaders.set("Cache-Control", "no-store") + responseHeaders.set("X-Content-Type-Options", "nosniff") + + const contentType = upstream.headers.get("content-type") ?? "" + + if (contentType.includes("text/html")) { + const html = await upstream.text() + if (html.length > MAX_RESPONSE_BYTES) { + return Response.json({ error: "Response too large" }, { status: 413 }) + } + const modified = injectBaseTag(html, buildBaseHref(parsedUrl)) + return new Response(modified, { + status: 200, + headers: responseHeaders, + }) + } + + // Non-HTML: stream body through + if (upstream.body) { + const { readable, writable } = new TransformStream() + upstream.body.pipeTo(writable).catch((err: unknown) => { + if ( + err instanceof Error && + err.name !== "AbortError" && + !err.message?.includes("ResponseAborted") + ) { + console.error("[Preview proxy] pipe error:", err) + } + }) + return new Response(readable, { + status: upstream.status, + headers: responseHeaders, + }) + } + + return new Response(null, { + status: upstream.status, + headers: responseHeaders, + }) +} diff --git a/components/ambient-ui/src/components/preview/comment-card.tsx b/components/ambient-ui/src/components/preview/comment-card.tsx new file mode 100644 index 000000000..8c3788352 --- /dev/null +++ b/components/ambient-ui/src/components/preview/comment-card.tsx @@ -0,0 +1,110 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { Card, CardContent } from '@/components/ui/card' + +export type CommentCardProps = { + type: 'element' | 'region' + position: { x: number; y: number } + dimensions?: { width: number; height: number } + capturedHtml?: string + onSubmit: (comment: string) => void + onCancel: () => void +} + +export function CommentCard({ + type, + position, + dimensions, + capturedHtml, + onSubmit, + onCancel, +}: CommentCardProps) { + const [comment, setComment] = useState('') + const textareaRef = useRef(null) + + useEffect(() => { + // Focus textarea when card mounts + const timer = setTimeout(() => textareaRef.current?.focus(), 50) + return () => clearTimeout(timer) + }, []) + + const handleSubmit = useCallback(() => { + if (comment.trim()) { + onSubmit(comment.trim()) + } + }, [comment, onSubmit]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + handleSubmit() + } + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + onCancel() + } + }, + [handleSubmit, onCancel] + ) + + const selectionLabel = + type === 'region' && dimensions + ? `Region: ${dimensions.width}x${dimensions.height}px at (${position.x}, ${position.y})` + : `Element at (${position.x}, ${position.y})` + + return ( + + +
+

+ {selectionLabel} +

+ {capturedHtml && ( +
+              {capturedHtml.slice(0, 200)}
+              {capturedHtml.length > 200 ? '...' : ''}
+            
+ )} +
+