diff --git a/components/ambient-ui/src/adapters/__tests__/mappers.test.ts b/components/ambient-ui/src/adapters/__tests__/mappers.test.ts index cc72f87f0b..290342c269 100644 --- a/components/ambient-ui/src/adapters/__tests__/mappers.test.ts +++ b/components/ambient-ui/src/adapters/__tests__/mappers.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' -import { mapSdkSessionToDomain, mapSdkProjectToDomain, mapSessionMessageToDomain } from '../mappers' +import { mapSdkSessionToDomain, mapSdkProjectToDomain, mapSessionMessageToDomain, mapSdkAgentToDomain } from '../mappers' import type { SdkSessionMessageShape } from '../mappers' -import type { Session, Project } from 'ambient-sdk' +import type { Session, Project, Agent } from 'ambient-sdk' function makeSdkSession(overrides: Partial = {}): Session { return { @@ -454,3 +454,118 @@ describe('mapSessionMessageToDomain', () => { expect(domain.payload).toBe(complexPayload) }) }) + +function makeSdkAgent(overrides: Partial = {}): Agent { + return { + id: 'agent-001', + kind: 'Agent', + href: '/api/ambient/v1/agents/agent-001', + created_at: '2026-02-01T09:00:00Z', + updated_at: '2026-02-01T10:00:00Z', + annotations: '{}', + bot_account_name: '', + current_session_id: '', + description: 'A test agent', + display_name: 'Test Agent', + environment_variables: '', + labels: '', + llm_max_tokens: 4096, + llm_model: 'claude-sonnet-4-20250514', + llm_temperature: 0.7, + name: 'test-agent', + owner_user_id: 'user-42', + parent_agent_id: '', + project_id: 'proj-123', + prompt: 'You are a helpful agent.', + repo_url: 'https://github.com/org/repo', + resource_overrides: '', + workflow_id: 'wf-1', + ...overrides, + } +} + +describe('mapSdkAgentToDomain', () => { + it('maps snake_case fields to camelCase', () => { + const sdk = makeSdkAgent() + const domain = mapSdkAgentToDomain(sdk) + + expect(domain.id).toBe('agent-001') + expect(domain.name).toBe('test-agent') + expect(domain.displayName).toBe('Test Agent') + expect(domain.description).toBe('A test agent') + expect(domain.model).toBe('claude-sonnet-4-20250514') + expect(domain.ownerUserId).toBe('user-42') + expect(domain.projectId).toBe('proj-123') + expect(domain.prompt).toBe('You are a helpful agent.') + expect(domain.repoUrl).toBe('https://github.com/org/repo') + expect(domain.workflowId).toBe('wf-1') + expect(domain.createdAt).toBe('2026-02-01T09:00:00Z') + expect(domain.updatedAt).toBe('2026-02-01T10:00:00Z') + }) + + it('maps empty string fields to null', () => { + const sdk = makeSdkAgent({ + display_name: '', + description: '', + llm_model: '', + owner_user_id: '', + current_session_id: '', + project_id: '', + prompt: '', + repo_url: '', + workflow_id: '', + }) + const domain = mapSdkAgentToDomain(sdk) + + expect(domain.displayName).toBeNull() + expect(domain.description).toBeNull() + expect(domain.model).toBeNull() + expect(domain.ownerUserId).toBeNull() + expect(domain.currentSessionId).toBeNull() + expect(domain.projectId).toBeNull() + expect(domain.prompt).toBeNull() + expect(domain.repoUrl).toBeNull() + expect(domain.workflowId).toBeNull() + }) + + it('parses valid annotations JSON to Record', () => { + const annotations = JSON.stringify({ team: 'platform', tier: 'production' }) + const sdk = makeSdkAgent({ annotations }) + const domain = mapSdkAgentToDomain(sdk) + + expect(domain.annotations).toEqual({ team: 'platform', tier: 'production' }) + }) + + it('returns empty Record for invalid annotations', () => { + const sdk = makeSdkAgent({ annotations: 'broken{' }) + const domain = mapSdkAgentToDomain(sdk) + expect(domain.annotations).toEqual({}) + }) + + it('parses valid labels JSON to Record', () => { + const labels = JSON.stringify({ env: 'dev', app: 'backend' }) + const sdk = makeSdkAgent({ labels }) + const domain = mapSdkAgentToDomain(sdk) + + expect(domain.labels).toEqual({ env: 'dev', app: 'backend' }) + }) + + it('returns empty Record for invalid labels', () => { + const sdk = makeSdkAgent({ labels: '["a"]' }) + const domain = mapSdkAgentToDomain(sdk) + expect(domain.labels).toEqual({}) + }) + + it('handles null created_at and updated_at', () => { + const sdk = makeSdkAgent({ created_at: null, updated_at: null }) + const domain = mapSdkAgentToDomain(sdk) + expect(domain.createdAt).toBe('') + expect(domain.updatedAt).toBe('') + }) + + it('maps current_session_id when present', () => { + const sdk = makeSdkAgent({ current_session_id: 'sess-abc' }) + const domain = mapSdkAgentToDomain(sdk) + expect(domain.currentSessionId).toBe('sess-abc') + }) +}) diff --git a/components/ambient-ui/src/adapters/mappers.ts b/components/ambient-ui/src/adapters/mappers.ts index 77f490eb19..3bdba65db7 100644 --- a/components/ambient-ui/src/adapters/mappers.ts +++ b/components/ambient-ui/src/adapters/mappers.ts @@ -1,6 +1,6 @@ -import type { Session, Project } from 'ambient-sdk' +import type { Session, Project, Agent } from 'ambient-sdk' import type { - DomainSession, DomainProject, DomainSessionMessage, SessionPhase, SessionEventType, + DomainSession, DomainProject, DomainSessionMessage, DomainAgent, SessionPhase, SessionEventType, DomainRepo, DomainReconciledRepo, DomainCondition, ReconciledRepoStatus, ConditionStatus, } from '@/domain/types' @@ -168,6 +168,26 @@ export function mapSdkProjectToDomain(sdk: Project): DomainProject { } } +export function mapSdkAgentToDomain(sdk: Agent): DomainAgent { + return { + id: sdk.id, + name: sdk.name, + displayName: emptyToNull(sdk.display_name), + description: emptyToNull(sdk.description), + model: emptyToNull(sdk.llm_model), + ownerUserId: emptyToNull(sdk.owner_user_id), + currentSessionId: emptyToNull(sdk.current_session_id), + projectId: emptyToNull(sdk.project_id), + prompt: emptyToNull(sdk.prompt), + repoUrl: emptyToNull(sdk.repo_url), + workflowId: emptyToNull(sdk.workflow_id), + annotations: parseAnnotations(sdk.annotations), + labels: parseJsonObject(sdk.labels), + createdAt: sdk.created_at ?? '', + updatedAt: sdk.updated_at ?? '', + } +} + export type SdkSessionMessageShape = { id: string session_id: string diff --git a/components/ambient-ui/src/adapters/sdk-agents.ts b/components/ambient-ui/src/adapters/sdk-agents.ts new file mode 100644 index 0000000000..8b2c0cdfa7 --- /dev/null +++ b/components/ambient-ui/src/adapters/sdk-agents.ts @@ -0,0 +1,49 @@ +import { AgentAPI } from 'ambient-sdk' +import type { AgentsPort } from '@/ports/agents' +import type { DomainAgent, ListParams, PaginatedResult } from '@/domain/types' +import { mapSdkAgentToDomain } from './mappers' +import { getConfig } from './sdk-client' + +function sanitizeSearch(value: string): string { + return value.replace(/['"%;\\]/g, '') +} + +function getProjectScopedAPI(projectId: string): AgentAPI { + return new AgentAPI({ ...getConfig(), project: projectId }) +} + +function buildSdkListOptions(params?: ListParams) { + return { + page: params?.page ?? 1, + size: params?.size ?? 20, + search: params?.search + ? `name like '%${sanitizeSearch(params.search)}%'` + : undefined, + orderBy: params?.orderBy, + } +} + +export function createAgentsAdapter(): AgentsPort { + return { + async list(projectId: string, params?: ListParams): Promise> { + const api = getProjectScopedAPI(projectId) + const opts = buildSdkListOptions(params) + const result = await api.list(opts) + const page = opts.page + const size = opts.size + return { + items: result.items.map(mapSdkAgentToDomain), + total: result.total, + page, + size, + hasMore: page * size < result.total, + } + }, + + async get(agentId: string): Promise { + const api = new AgentAPI({ ...getConfig(), project: '_' }) + const agent = await api.get(agentId) + return mapSdkAgentToDomain(agent) + }, + } +} diff --git a/components/ambient-ui/src/adapters/sdk-client.ts b/components/ambient-ui/src/adapters/sdk-client.ts index 597020ac37..bc4d740393 100644 --- a/components/ambient-ui/src/adapters/sdk-client.ts +++ b/components/ambient-ui/src/adapters/sdk-client.ts @@ -13,7 +13,6 @@ const bffConfig: AmbientClientConfig = { let sessions: SessionAPI | null = null let projects: ProjectAPI | null = null - export function getSessionAPI(): SessionAPI { if (!sessions) { sessions = new SessionAPI(bffConfig) diff --git a/components/ambient-ui/src/adapters/sdk-sessions.ts b/components/ambient-ui/src/adapters/sdk-sessions.ts index d2f7d7e881..2f6c2f9770 100644 --- a/components/ambient-ui/src/adapters/sdk-sessions.ts +++ b/components/ambient-ui/src/adapters/sdk-sessions.ts @@ -1,6 +1,6 @@ -import type { SessionAPI } from 'ambient-sdk' +import type { SessionAPI, SessionCreateRequest } from 'ambient-sdk' import type { SessionsPort } from '@/ports/sessions' -import type { DomainSession, ListParams, PaginatedResult } from '@/domain/types' +import type { DomainSession, DomainSessionCreateRequest, ListParams, PaginatedResult } from '@/domain/types' import { mapSdkSessionToDomain } from './mappers' import { getSessionAPI } from './sdk-client' @@ -21,6 +21,20 @@ function buildSdkListOptions(projectId: string, params?: ListParams) { } } +function mapDomainCreateToSdk(request: DomainSessionCreateRequest): SessionCreateRequest { + const sdkReq: SessionCreateRequest = { + name: request.name, + project_id: request.projectId, + } + if (request.agentId) sdkReq.agent_id = request.agentId + if (request.prompt) sdkReq.prompt = request.prompt + if (request.model) sdkReq.llm_model = request.model + if (request.temperature !== undefined) sdkReq.llm_temperature = request.temperature + if (request.maxTokens !== undefined) sdkReq.llm_max_tokens = request.maxTokens + if (request.timeout !== undefined) sdkReq.timeout = request.timeout + return sdkReq +} + function createSdkSessionsAdapter(api: SessionAPI): SessionsPort { return { async list(projectId: string, params?: ListParams): Promise> { @@ -43,6 +57,12 @@ function createSdkSessionsAdapter(api: SessionAPI): SessionsPort { return mapSdkSessionToDomain(session) }, + async create(request: DomainSessionCreateRequest): Promise { + const sdkReq = mapDomainCreateToSdk(request) + const session = await api.create(sdkReq) + return mapSdkSessionToDomain(session) + }, + async stop(sessionId: string): Promise { await api.stop(sessionId) }, diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/agents/_components/agent-detail-panel.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/agents/_components/agent-detail-panel.tsx new file mode 100644 index 0000000000..1bef9437d4 --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/agents/_components/agent-detail-panel.tsx @@ -0,0 +1,245 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import type { LucideIcon } from 'lucide-react' +import { + Pin, + Tag, + Ticket, + GitPullRequest, + GitBranch, + FolderGit2, + Layers, + ExternalLink, + MessageCircle, + User, + Play, + DollarSign, + Siren, + Bot, + AlertTriangle, +} from 'lucide-react' +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +} from '@/components/ui/sheet' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Badge } from '@/components/ui/badge' +import { MetaRow, NoValue } from '@/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/meta-row' +import { getRegisteredAnnotation } from '@/domain/annotations' +import type { DomainAgent } from '@/domain/types' +import { formatRelativeTime } from '@/lib/format-timestamp' + +const ICON_MAP: Record = { + pin: Pin, tag: Tag, ticket: Ticket, layers: Layers, play: Play, bot: Bot, + siren: Siren, user: User, 'dollar-sign': DollarSign, + 'git-pull-request': GitPullRequest, 'git-branch': GitBranch, + 'folder-git-2': FolderGit2, 'external-link': ExternalLink, + 'message-circle': MessageCircle, 'alert-triangle': AlertTriangle, +} + +const PROMPT_TRUNCATE_LENGTH = 200 + +function isClickableValue(value: string): boolean { + return /^https?:\/\//.test(value) +} + +export function AgentDetailPanel({ + agent, + projectId, + onClose, +}: { + agent: DomainAgent | null + projectId: string + onClose: () => void +}) { + const [promptExpanded, setPromptExpanded] = useState(false) + + const isOpen = agent !== null + + const annotationEntries = agent ? Object.entries(agent.annotations) : [] + const labelEntries = agent ? Object.entries(agent.labels) : [] + + const promptNeedsTruncation = + agent?.prompt != null && agent.prompt.length > PROMPT_TRUNCATE_LENGTH + const displayPrompt = + agent?.prompt != null + ? promptNeedsTruncation && !promptExpanded + ? agent.prompt.slice(0, PROMPT_TRUNCATE_LENGTH) + '...' + : agent.prompt + : null + + return ( + { if (!open) onClose() }}> + + {agent && ( + <> + + {agent.displayName ?? agent.name} + {agent.displayName && ( + + {agent.name} + + )} + + +
+ + + Quick Info + + +
+ } /> + } /> + e.stopPropagation()} + > + {agent.currentSessionId} + + ) : ( + + ) + } + /> + + {agent.repoUrl} + + ) : ( + + ) + } + /> + {agent.workflowId} : } /> + } /> + } /> + } /> +
+
+
+ + {displayPrompt != null && ( + + + Prompt + + +
{displayPrompt}
+ {promptNeedsTruncation && ( + + )} +
+
+ )} + + {annotationEntries.length > 0 && ( + + + + Annotations ({annotationEntries.length}) + + + + + + + Key + Value + + + + {annotationEntries.map(([key, value]) => { + const registered = getRegisteredAnnotation(key) + const Icon = registered?.icon ? ICON_MAP[registered.icon] : null + const clickable = isClickableValue(value) + return ( + + + + {Icon && } + {registered ? registered.label : key} + + + + {clickable ? ( + + {value} + + ) : ( + value + )} + + + ) + })} + +
+
+
+ )} + + {labelEntries.length > 0 && ( + + + + Labels ({labelEntries.length}) + + + +
+ {labelEntries.map(([key, value]) => ( + + {key}: {value} + + ))} +
+
+
+ )} +
+ + )} +
+
+ ) +} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/agents/_components/agents-table.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/agents/_components/agents-table.tsx new file mode 100644 index 0000000000..fd8c4e7ba8 --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/agents/_components/agents-table.tsx @@ -0,0 +1,180 @@ +'use client' + +import { useState } from 'react' +import { + useReactTable, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + createColumnHelper, + flexRender, +} from '@tanstack/react-table' +import type { SortingState } from '@tanstack/react-table' +import { ChevronUp, ChevronDown } from 'lucide-react' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import type { DomainAgent } from '@/domain/types' +import { formatRelativeTime } from '@/lib/format-timestamp' + +const col = createColumnHelper() + +const agentColumns = [ + col.accessor((row) => row.displayName ?? row.name, { + id: 'name', + header: 'Name', + cell: info => ( +
+ {info.getValue()} + {info.row.original.displayName && ( + + {info.row.original.name} + + )} +
+ ), + }), + col.accessor('model', { + header: 'Model', + cell: info => ( + + {info.getValue() ?? '—'} + + ), + }), + col.accessor('ownerUserId', { + header: 'Owner', + cell: info => ( + + {info.getValue() ?? '—'} + + ), + }), + col.accessor('currentSessionId', { + header: 'Current Session', + cell: info => { + const sessionId = info.getValue() + if (!sessionId) return {'—'} + return ( + + {sessionId} + + ) + }, + }), + col.display({ + id: 'lastActive', + header: 'Last Active', + enableSorting: true, + sortingFn: (rowA, rowB) => { + return new Date(rowA.original.updatedAt).getTime() - new Date(rowB.original.updatedAt).getTime() + }, + cell: ({ row }) => ( + + {row.original.updatedAt ? formatRelativeTime(row.original.updatedAt) : '—'} + + ), + }), +] + +export function AgentsTable({ + agents, + searchFilter, + onSelectAgent, +}: { + agents: DomainAgent[] + searchFilter: string + onSelectAgent: (agent: DomainAgent) => void +}) { + const [sorting, setSorting] = useState([ + { id: 'lastActive', desc: true }, + ]) + + const table = useReactTable({ + data: agents, + columns: agentColumns, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + globalFilterFn: 'includesString', + state: { + globalFilter: searchFilter, + sorting, + }, + onSortingChange: setSorting, + }) + + return ( +
+ + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => { + const canSort = header.column.getCanSort() + const sorted = header.column.getIsSorted() + + return ( + +
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + {canSort && sorted === 'asc' && ( + + )} + {canSort && sorted === 'desc' && ( + + )} + {canSort && !sorted && ( + + )} +
+
+ ) + })} +
+ ))} +
+ + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map(row => ( + onSelectAgent(row.original)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + onSelectAgent(row.original) + } + }} + > + {row.getVisibleCells().map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No agents match your filter. + + + )} + +
+
+ ) +} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/agents/page.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/agents/page.tsx new file mode 100644 index 0000000000..2292e74554 --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/agents/page.tsx @@ -0,0 +1,81 @@ +'use client' + +import { useState } from 'react' +import { useParams } from 'next/navigation' +import { Bot } from 'lucide-react' +import { Input } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' +import { EmptyState } from '@/components/empty-state' +import { useAgents } from '@/queries/use-agents' +import type { DomainAgent } from '@/domain/types' +import { AgentsTable } from './_components/agents-table' +import { AgentDetailPanel } from './_components/agent-detail-panel' + +export default function AgentsPage() { + const { projectId } = useParams<{ projectId: string }>() + const [search, setSearch] = useState('') + const [selectedAgent, setSelectedAgent] = useState(null) + const { data, isLoading, error } = useAgents(projectId) + + if (error) { + return ( +
+

Agents

+

+ Failed to load agents: {error.message} +

+
+ ) + } + + if (isLoading) { + return ( +
+

Agents

+
+ + +
+
+ ) + } + + const agents = data?.items ?? [] + + if (agents.length === 0) { + return ( +
+

Agents

+ +
+ ) + } + + return ( +
+
+

Agents

+ setSearch(e.target.value)} + className="max-w-xs" + /> +
+ + setSelectedAgent(null)} + /> +
+ ) +} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/create-session-sheet.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/create-session-sheet.tsx new file mode 100644 index 0000000000..408990988f --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/create-session-sheet.tsx @@ -0,0 +1,260 @@ +'use client' + +import { useState } from 'react' +import { useRouter, useParams } from 'next/navigation' +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter, +} from '@/components/ui/sheet' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { useCreateSession } from '@/queries/use-sessions' +import { useAgents } from '@/queries/use-agents' +import type { DomainSessionCreateRequest } from '@/domain/types' + +const MODEL_OPTIONS = [ + 'claude-sonnet-4-20250514', + 'claude-opus-4-20250514', + 'claude-haiku-35-20241022', +] as const + +export function CreateSessionSheet({ + open, + onOpenChange, +}: { + open: boolean + onOpenChange: (open: boolean) => void +}) { + const router = useRouter() + const { projectId } = useParams<{ projectId: string }>() + const createSession = useCreateSession() + const { data: agentsData } = useAgents(projectId, { size: 100 }) + + const [name, setName] = useState('') + const [agentId, setAgentId] = useState('') + const [prompt, setPrompt] = useState('') + const [model, setModel] = useState('') + const [showAdvanced, setShowAdvanced] = useState(false) + const [temperature, setTemperature] = useState('') + const [maxTokens, setMaxTokens] = useState('') + const [timeout, setTimeout] = useState('') + const [error, setError] = useState(null) + + const agents = agentsData?.items ?? [] + + function resetForm() { + setName('') + setAgentId('') + setPrompt('') + setModel('') + setShowAdvanced(false) + setTemperature('') + setMaxTokens('') + setTimeout('') + setError(null) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + + if (!name.trim()) { + setError('Name is required.') + return + } + + const request: DomainSessionCreateRequest = { + name: name.trim(), + projectId, + } + + if (agentId) request.agentId = agentId + if (prompt.trim()) request.prompt = prompt.trim() + if (model) request.model = model + + if (showAdvanced) { + const tempVal = parseFloat(temperature) + if (!isNaN(tempVal)) request.temperature = tempVal + + const maxTokVal = parseInt(maxTokens, 10) + if (!isNaN(maxTokVal) && maxTokVal > 0) request.maxTokens = maxTokVal + + const timeoutVal = parseInt(timeout, 10) + if (!isNaN(timeoutVal) && timeoutVal > 0) request.timeout = timeoutVal + } + + try { + const session = await createSession.mutateAsync(request) + resetForm() + onOpenChange(false) + router.push(`/${projectId}/sessions/${session.id}`) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create session.') + } + } + + return ( + { if (!v) resetForm(); onOpenChange(v) }}> + + + New Session + + Create a new agentic session in this project. + + + +
+
+ + setName(e.target.value)} + required + /> +
+ +
+ + +
+ +
+ +