From c85bfe57c841fe0af8a00275027956125b1a1688 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 3 Jun 2026 12:54:57 -0400 Subject: [PATCH 1/5] fix(ambient-ui): address CodeRabbit review findings from #1638 - Guard JSON array parsers against null/primitive entries (isRecord filter) - Preserve temperature=0 as valid (only null maxTokens/timeout at zero) - Normalize repo URLs when merging to avoid .git suffix mismatches - Fix hydration mismatch: initialize tab state server-safe, sync from URL in useEffect Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/adapters/__tests__/mappers.test.ts | 4 +- components/ambient-ui/src/adapters/mappers.ts | 71 +++++++++++-------- .../[sessionId]/_components/resources-tab.tsx | 13 ++-- .../[projectId]/sessions/[sessionId]/page.tsx | 12 ++-- 4 files changed, 58 insertions(+), 42 deletions(-) diff --git a/components/ambient-ui/src/adapters/__tests__/mappers.test.ts b/components/ambient-ui/src/adapters/__tests__/mappers.test.ts index 156b4fdf1f..cc72f87f0b 100644 --- a/components/ambient-ui/src/adapters/__tests__/mappers.test.ts +++ b/components/ambient-ui/src/adapters/__tests__/mappers.test.ts @@ -333,11 +333,11 @@ describe('mapSdkSessionToDomain', () => { expect(domain.timeout).toBe(7200) }) - it('returns null for zero temperature, maxTokens, timeout', () => { + it('preserves zero temperature but nulls zero maxTokens and timeout', () => { const sdk = makeSdkSession({ llm_temperature: 0, llm_max_tokens: 0, timeout: 0 }) const domain = mapSdkSessionToDomain(sdk) - expect(domain.temperature).toBeNull() + expect(domain.temperature).toBe(0) expect(domain.maxTokens).toBeNull() expect(domain.timeout).toBeNull() }) diff --git a/components/ambient-ui/src/adapters/mappers.ts b/components/ambient-ui/src/adapters/mappers.ts index a648c67efb..82d1bf2aa5 100644 --- a/components/ambient-ui/src/adapters/mappers.ts +++ b/components/ambient-ui/src/adapters/mappers.ts @@ -70,45 +70,50 @@ function parseJsonObject(raw: string): Record { const VALID_REPO_STATUSES: ReadonlySet = new Set(['Cloning', 'Ready', 'Failed']) const VALID_CONDITION_STATUSES: ReadonlySet = new Set(['True', 'False', 'Unknown']) +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + function parseRepos(raw: string): DomainRepo[] { - return parseJsonArray(raw).map((item) => { - const r = item as Record - return { + return parseJsonArray(raw) + .filter(isRecord) + .map((r) => ({ url: String(r.url ?? ''), branch: r.branch ? String(r.branch) : null, name: r.name ? String(r.name) : null, autoPush: Boolean(r.autoPush), - } - }) + })) } function parseReconciledRepos(raw: string): DomainReconciledRepo[] { - return parseJsonArray(raw).map((item) => { - const r = item as Record - const status = String(r.status ?? '') - return { - url: String(r.url ?? ''), - name: r.name ? String(r.name) : null, - status: VALID_REPO_STATUSES.has(status) ? (status as ReconciledRepoStatus) : null, - currentActiveBranch: r.currentActiveBranch ? String(r.currentActiveBranch) : null, - defaultBranch: r.defaultBranch ? String(r.defaultBranch) : null, - clonedAt: r.clonedAt ? String(r.clonedAt) : null, - } - }) + return parseJsonArray(raw) + .filter(isRecord) + .map((r) => { + const status = String(r.status ?? '') + return { + url: String(r.url ?? ''), + name: r.name ? String(r.name) : null, + status: VALID_REPO_STATUSES.has(status) ? (status as ReconciledRepoStatus) : null, + currentActiveBranch: r.currentActiveBranch ? String(r.currentActiveBranch) : null, + defaultBranch: r.defaultBranch ? String(r.defaultBranch) : null, + clonedAt: r.clonedAt ? String(r.clonedAt) : null, + } + }) } function parseConditions(raw: string): DomainCondition[] { - return parseJsonArray(raw).map((item) => { - const c = item as Record - const status = String(c.status ?? 'Unknown') - return { - type: String(c.type ?? ''), - status: VALID_CONDITION_STATUSES.has(status) ? (status as ConditionStatus) : 'Unknown', - reason: c.reason ? String(c.reason) : null, - message: c.message ? String(c.message) : null, - lastTransitionTime: c.lastTransitionTime ? String(c.lastTransitionTime) : null, - } - }) + return parseJsonArray(raw) + .filter(isRecord) + .map((c) => { + const status = String(c.status ?? 'Unknown') + return { + type: String(c.type ?? ''), + status: VALID_CONDITION_STATUSES.has(status) ? (status as ConditionStatus) : 'Unknown', + reason: c.reason ? String(c.reason) : null, + message: c.message ? String(c.message) : null, + lastTransitionTime: c.lastTransitionTime ? String(c.lastTransitionTime) : null, + } + }) } function emptyToNull(value: string): string | null { @@ -116,7 +121,11 @@ function emptyToNull(value: string): string | null { } function numberOrNull(value: number): number | null { - return value === 0 || value === undefined || value === null ? null : value + return value === undefined || value === null ? null : value +} + +function positiveNumberOrNull(value: number): number | null { + return value === undefined || value === null || value === 0 ? null : value } export function mapSdkSessionToDomain(sdk: Session): DomainSession { @@ -130,8 +139,8 @@ export function mapSdkSessionToDomain(sdk: Session): DomainSession { projectId: emptyToNull(sdk.project_id), model: emptyToNull(sdk.llm_model), temperature: numberOrNull(sdk.llm_temperature), - maxTokens: numberOrNull(sdk.llm_max_tokens), - timeout: numberOrNull(sdk.timeout), + maxTokens: positiveNumberOrNull(sdk.llm_max_tokens), + timeout: positiveNumberOrNull(sdk.timeout), workflowId: emptyToNull(sdk.workflow_id), prompt: emptyToNull(sdk.prompt), sdkRestartCount: sdk.sdk_restart_count ?? 0, diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/resources-tab.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/resources-tab.tsx index 30cb4db5a3..88bcdf9a7c 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/resources-tab.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/resources-tab.tsx @@ -29,20 +29,25 @@ type MergedRepo = { clonedAt: string | null } +function normalizeUrl(url: string): string { + return url.replace(/\.git$/, '').replace(/\/$/, '') +} + function mergeRepos( repos: DomainRepo[], reconciledRepos: DomainReconciledRepo[], ): MergedRepo[] { const reconciledByUrl = new Map( - reconciledRepos.map(r => [r.url, r]), + reconciledRepos.map(r => [normalizeUrl(r.url), r]), ) const seen = new Set() const result: MergedRepo[] = [] for (const repo of repos) { - seen.add(repo.url) - const reconciled = reconciledByUrl.get(repo.url) + const key = normalizeUrl(repo.url) + seen.add(key) + const reconciled = reconciledByUrl.get(key) result.push({ url: repo.url, name: reconciled?.name ?? repo.name ?? baseNameFromUrl(repo.url), @@ -53,7 +58,7 @@ function mergeRepos( } for (const reconciled of reconciledRepos) { - if (!seen.has(reconciled.url)) { + if (!seen.has(normalizeUrl(reconciled.url))) { result.push({ url: reconciled.url, name: reconciled.name ?? baseNameFromUrl(reconciled.url), diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/page.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/page.tsx index 251d3c73a9..bf38f1c2fe 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/page.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useParams } from 'next/navigation' import { Skeleton } from '@/components/ui/skeleton' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' @@ -21,12 +21,14 @@ import { ConfigTab } from './_components/config-tab' export default function SessionDetailPage() { const { sessionId } = useParams<{ projectId: string; sessionId: string }>() - const [activeTab, setActiveTab] = useState(() => { - if (typeof window === 'undefined') return 'overview' - return new URL(window.location.href).searchParams.get('tab') ?? 'overview' - }) + const [activeTab, setActiveTab] = useState('overview') const { data: session, isLoading, error } = useSession(sessionId) + useEffect(() => { + const tab = new URL(window.location.href).searchParams.get('tab') + if (tab) setActiveTab(tab) + }, []) + const handleTabChange = (value: string) => { setActiveTab(value) const url = new URL(window.location.href) From 50a861c11318d765a0e15537bdd9de0b11c1268f Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 3 Jun 2026 13:04:51 -0400 Subject: [PATCH 2/5] fix(ambient-ui): widen number helper signatures, normalize baseNameFromUrl - Widen numberOrNull/positiveNumberOrNull params to number | null | undefined - Use normalizeUrl in baseNameFromUrl to handle trailing slashes and .git Co-Authored-By: Claude Opus 4.6 (1M context) --- components/ambient-ui/src/adapters/mappers.ts | 4 ++-- .../sessions/[sessionId]/_components/resources-tab.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/components/ambient-ui/src/adapters/mappers.ts b/components/ambient-ui/src/adapters/mappers.ts index 82d1bf2aa5..77f490eb19 100644 --- a/components/ambient-ui/src/adapters/mappers.ts +++ b/components/ambient-ui/src/adapters/mappers.ts @@ -120,11 +120,11 @@ function emptyToNull(value: string): string | null { return value || null } -function numberOrNull(value: number): number | null { +function numberOrNull(value: number | null | undefined): number | null { return value === undefined || value === null ? null : value } -function positiveNumberOrNull(value: number): number | null { +function positiveNumberOrNull(value: number | null | undefined): number | null { return value === undefined || value === null || value === 0 ? null : value } diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/resources-tab.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/resources-tab.tsx index 88bcdf9a7c..8a5812dada 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/resources-tab.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/resources-tab.tsx @@ -73,7 +73,7 @@ function mergeRepos( } function baseNameFromUrl(url: string): string { - const segments = url.replace(/\.git$/, '').split('/') + const segments = normalizeUrl(url).split('/').filter(Boolean) return segments[segments.length - 1] || url } From f0ec9004e746238faeec588c69cb449d52654475 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 3 Jun 2026 13:20:21 -0400 Subject: [PATCH 3/5] feat(ambient-ui): add Agents view and Session creation - Agents view: table with name, model, owner, current session link, last active. Row click opens detail panel (Sheet) showing quick info, expandable prompt, and annotations - Session create: Sheet form with name, agent select, prompt, model, and collapsible advanced settings (temperature, max tokens, timeout) - Domain types, ports, adapters, mappers for agents (full port/adapter layer) - Session create port/adapter extension with domain-to-SDK mapping - useCreateSession mutation hook with cache invalidation - Agents nav item in sidebar - "+ New Session" button on sessions page and empty state Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/adapters/__tests__/mappers.test.ts | 119 +++++++- components/ambient-ui/src/adapters/mappers.ts | 24 +- .../ambient-ui/src/adapters/sdk-agents.ts | 50 ++++ .../ambient-ui/src/adapters/sdk-client.ts | 10 +- .../ambient-ui/src/adapters/sdk-sessions.ts | 24 +- .../agents/_components/agent-detail-panel.tsx | 245 +++++++++++++++++ .../agents/_components/agents-table.tsx | 180 ++++++++++++ .../(dashboard)/[projectId]/agents/page.tsx | 81 ++++++ .../_components/create-session-sheet.tsx | 260 ++++++++++++++++++ .../(dashboard)/[projectId]/sessions/page.tsx | 31 ++- .../ambient-ui/src/components/app-sidebar.tsx | 1 + components/ambient-ui/src/domain/types.ts | 29 ++ components/ambient-ui/src/ports/agents.ts | 6 + components/ambient-ui/src/ports/sessions.ts | 3 +- .../ambient-ui/src/queries/query-keys.ts | 6 + .../ambient-ui/src/queries/use-agents.ts | 37 +++ .../ambient-ui/src/queries/use-sessions.ts | 14 +- 17 files changed, 1104 insertions(+), 16 deletions(-) create mode 100644 components/ambient-ui/src/adapters/sdk-agents.ts create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/agents/_components/agent-detail-panel.tsx create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/agents/_components/agents-table.tsx create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/agents/page.tsx create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/create-session-sheet.tsx create mode 100644 components/ambient-ui/src/ports/agents.ts 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..0a95c06b4d --- /dev/null +++ b/components/ambient-ui/src/adapters/sdk-agents.ts @@ -0,0 +1,50 @@ +import type { AgentAPI } from 'ambient-sdk' +import type { AgentsPort } from '@/ports/agents' +import type { DomainAgent, ListParams, PaginatedResult } from '@/domain/types' +import { mapSdkAgentToDomain } from './mappers' +import { getAgentAPI } from './sdk-client' + +function sanitizeSearch(value: string): string { + return value.replace(/['"%;\\]/g, '') +} + +function buildSdkListOptions(projectId: string, params?: ListParams) { + const search = params?.search + ? `project_id = '${sanitizeSearch(projectId)}' and name like '%${sanitizeSearch(params.search)}%'` + : `project_id = '${sanitizeSearch(projectId)}'` + + return { + page: params?.page ?? 1, + size: params?.size ?? 20, + search, + orderBy: params?.orderBy, + } +} + +function createSdkAgentsAdapter(api: AgentAPI): AgentsPort { + return { + async list(projectId: string, params?: ListParams): Promise> { + const opts = buildSdkListOptions(projectId, params) + const result = await api.list(opts) + const items = result.items.map(mapSdkAgentToDomain) + const page = opts.page + const size = opts.size + return { + items, + total: result.total, + page, + size, + hasMore: page * size < result.total, + } + }, + + async get(agentId: string): Promise { + const agent = await api.get(agentId) + return mapSdkAgentToDomain(agent) + }, + } +} + +export function createAgentsAdapter(api?: AgentAPI): AgentsPort { + return createSdkAgentsAdapter(api ?? getAgentAPI()) +} diff --git a/components/ambient-ui/src/adapters/sdk-client.ts b/components/ambient-ui/src/adapters/sdk-client.ts index 597020ac37..f94d7bee3b 100644 --- a/components/ambient-ui/src/adapters/sdk-client.ts +++ b/components/ambient-ui/src/adapters/sdk-client.ts @@ -1,5 +1,5 @@ import type { AmbientClientConfig } from 'ambient-sdk' -import { SessionAPI, ProjectAPI } from 'ambient-sdk' +import { SessionAPI, ProjectAPI, AgentAPI } from 'ambient-sdk' // BFF proxy config: empty baseUrl produces relative URLs like /api/ambient/v1/sessions // which hit the Next.js BFF proxy. No token needed — the proxy adds it server-side. @@ -13,6 +13,7 @@ const bffConfig: AmbientClientConfig = { let sessions: SessionAPI | null = null let projects: ProjectAPI | null = null +let agents: AgentAPI | null = null export function getSessionAPI(): SessionAPI { if (!sessions) { @@ -28,6 +29,13 @@ export function getProjectAPI(): ProjectAPI { return projects } +export function getAgentAPI(): AgentAPI { + if (!agents) { + agents = new AgentAPI(bffConfig) + } + return agents +} + export function getConfig(): AmbientClientConfig { return 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 + /> +
+ +
+ + +
+ +
+ +