diff --git a/components/ambient-ui/src/adapters/__tests__/mappers.test.ts b/components/ambient-ui/src/adapters/__tests__/mappers.test.ts index 718184838..156b4fdf1 100644 --- a/components/ambient-ui/src/adapters/__tests__/mappers.test.ts +++ b/components/ambient-ui/src/adapters/__tests__/mappers.test.ts @@ -170,6 +170,214 @@ describe('mapSdkSessionToDomain', () => { expect(domain.createdAt).toBe('') expect(domain.updatedAt).toBe('') }) + + describe('new session fields', () => { + it('parses repos from valid JSON string', () => { + const repos = JSON.stringify([ + { url: 'https://github.com/org/repo1', branch: 'main', name: 'repo1', autoPush: true }, + { url: 'https://github.com/org/repo2', branch: null, name: null, autoPush: false }, + ]) + const sdk = makeSdkSession({ repos }) + const domain = mapSdkSessionToDomain(sdk) + + expect(domain.repos).toHaveLength(2) + expect(domain.repos[0]).toEqual({ + url: 'https://github.com/org/repo1', + branch: 'main', + name: 'repo1', + autoPush: true, + }) + expect(domain.repos[1]).toEqual({ + url: 'https://github.com/org/repo2', + branch: null, + name: null, + autoPush: false, + }) + }) + + it('returns empty repos for empty string', () => { + const sdk = makeSdkSession({ repos: '' }) + const domain = mapSdkSessionToDomain(sdk) + expect(domain.repos).toEqual([]) + }) + + it('returns empty repos for invalid JSON', () => { + const sdk = makeSdkSession({ repos: 'not valid json' }) + const domain = mapSdkSessionToDomain(sdk) + expect(domain.repos).toEqual([]) + }) + + it('parses reconciled repos with all status variants', () => { + const reconciledRepos = JSON.stringify([ + { url: 'https://github.com/org/repo1', name: 'repo1', status: 'Cloning', currentActiveBranch: 'feat-1', defaultBranch: 'main', clonedAt: '2026-01-15T10:00:00Z' }, + { url: 'https://github.com/org/repo2', name: 'repo2', status: 'Ready', currentActiveBranch: 'main', defaultBranch: 'main', clonedAt: '2026-01-15T10:01:00Z' }, + { url: 'https://github.com/org/repo3', name: 'repo3', status: 'Failed', currentActiveBranch: null, defaultBranch: null, clonedAt: null }, + ]) + const sdk = makeSdkSession({ reconciled_repos: reconciledRepos }) + const domain = mapSdkSessionToDomain(sdk) + + expect(domain.reconciledRepos).toHaveLength(3) + expect(domain.reconciledRepos[0]).toEqual({ + url: 'https://github.com/org/repo1', + name: 'repo1', + status: 'Cloning', + currentActiveBranch: 'feat-1', + defaultBranch: 'main', + clonedAt: '2026-01-15T10:00:00Z', + }) + expect(domain.reconciledRepos[1]!.status).toBe('Ready') + expect(domain.reconciledRepos[2]!.status).toBe('Failed') + expect(domain.reconciledRepos[2]!.currentActiveBranch).toBeNull() + expect(domain.reconciledRepos[2]!.clonedAt).toBeNull() + }) + + it('returns null status for invalid reconciled repo status', () => { + const reconciledRepos = JSON.stringify([ + { url: 'https://github.com/org/repo1', name: 'repo1', status: 'SomeBogusStatus' }, + ]) + const sdk = makeSdkSession({ reconciled_repos: reconciledRepos }) + const domain = mapSdkSessionToDomain(sdk) + + expect(domain.reconciledRepos).toHaveLength(1) + expect(domain.reconciledRepos[0]!.status).toBeNull() + }) + + it('parses conditions array', () => { + const conditions = JSON.stringify([ + { type: 'Ready', status: 'True', reason: 'AllGood', message: 'Session is ready', lastTransitionTime: '2026-01-15T10:05:00Z' }, + { type: 'Progressing', status: 'False', reason: null, message: null, lastTransitionTime: null }, + ]) + const sdk = makeSdkSession({ conditions }) + const domain = mapSdkSessionToDomain(sdk) + + expect(domain.conditions).toHaveLength(2) + expect(domain.conditions[0]).toEqual({ + type: 'Ready', + status: 'True', + reason: 'AllGood', + message: 'Session is ready', + lastTransitionTime: '2026-01-15T10:05:00Z', + }) + expect(domain.conditions[1]).toEqual({ + type: 'Progressing', + status: 'False', + reason: null, + message: null, + lastTransitionTime: null, + }) + }) + + it('returns Unknown for invalid condition status', () => { + const conditions = JSON.stringify([ + { type: 'Ready', status: 'Maybe' }, + ]) + const sdk = makeSdkSession({ conditions }) + const domain = mapSdkSessionToDomain(sdk) + + expect(domain.conditions).toHaveLength(1) + expect(domain.conditions[0]!.status).toBe('Unknown') + }) + + it('parses environment variables from JSON string', () => { + const envVars = JSON.stringify({ NODE_ENV: 'production', API_URL: 'https://api.example.com' }) + const sdk = makeSdkSession({ environment_variables: envVars }) + const domain = mapSdkSessionToDomain(sdk) + + expect(domain.environmentVariables).toEqual({ + NODE_ENV: 'production', + API_URL: 'https://api.example.com', + }) + }) + + it('parses labels from JSON string', () => { + const labels = JSON.stringify({ team: 'platform', tier: 'production' }) + const sdk = makeSdkSession({ labels }) + const domain = mapSdkSessionToDomain(sdk) + + expect(domain.labels).toEqual({ + team: 'platform', + tier: 'production', + }) + }) + + it('returns empty object for invalid env vars JSON', () => { + const sdk = makeSdkSession({ environment_variables: '{broken' }) + const domain = mapSdkSessionToDomain(sdk) + expect(domain.environmentVariables).toEqual({}) + }) + + it('returns empty object for invalid labels JSON', () => { + const sdk = makeSdkSession({ labels: 'not-json' }) + const domain = mapSdkSessionToDomain(sdk) + expect(domain.labels).toEqual({}) + }) + + it('returns empty object for array-shaped env vars', () => { + const sdk = makeSdkSession({ environment_variables: '["a","b"]' }) + const domain = mapSdkSessionToDomain(sdk) + expect(domain.environmentVariables).toEqual({}) + }) + + it('returns empty object for array-shaped labels', () => { + const sdk = makeSdkSession({ labels: '["x"]' }) + const domain = mapSdkSessionToDomain(sdk) + expect(domain.labels).toEqual({}) + }) + + it('maps temperature, maxTokens, timeout from SDK numbers', () => { + const sdk = makeSdkSession({ llm_temperature: 0.5, llm_max_tokens: 8192, timeout: 7200 }) + const domain = mapSdkSessionToDomain(sdk) + + expect(domain.temperature).toBe(0.5) + expect(domain.maxTokens).toBe(8192) + expect(domain.timeout).toBe(7200) + }) + + it('returns null for zero temperature, maxTokens, timeout', () => { + const sdk = makeSdkSession({ llm_temperature: 0, llm_max_tokens: 0, timeout: 0 }) + const domain = mapSdkSessionToDomain(sdk) + + expect(domain.temperature).toBeNull() + expect(domain.maxTokens).toBeNull() + expect(domain.timeout).toBeNull() + }) + + it('maps workflowId from workflow_id', () => { + const sdk = makeSdkSession({ workflow_id: 'wf-42' }) + const domain = mapSdkSessionToDomain(sdk) + expect(domain.workflowId).toBe('wf-42') + }) + + it('maps workflowId to null for empty string', () => { + const sdk = makeSdkSession({ workflow_id: '' }) + const domain = mapSdkSessionToDomain(sdk) + expect(domain.workflowId).toBeNull() + }) + + it('maps prompt from SDK', () => { + const sdk = makeSdkSession({ prompt: 'Fix the bug in auth.ts' }) + const domain = mapSdkSessionToDomain(sdk) + expect(domain.prompt).toBe('Fix the bug in auth.ts') + }) + + it('maps prompt to null for empty string', () => { + const sdk = makeSdkSession({ prompt: '' }) + const domain = mapSdkSessionToDomain(sdk) + expect(domain.prompt).toBeNull() + }) + + it('maps sdkRestartCount from sdk_restart_count', () => { + const sdk = makeSdkSession({ sdk_restart_count: 3 }) + const domain = mapSdkSessionToDomain(sdk) + expect(domain.sdkRestartCount).toBe(3) + }) + + it('defaults sdkRestartCount to 0 when sdk_restart_count is 0', () => { + const sdk = makeSdkSession({ sdk_restart_count: 0 }) + const domain = mapSdkSessionToDomain(sdk) + expect(domain.sdkRestartCount).toBe(0) + }) + }) }) describe('mapSdkProjectToDomain', () => { diff --git a/components/ambient-ui/src/adapters/mappers.ts b/components/ambient-ui/src/adapters/mappers.ts index fd783b10f..a648c67ef 100644 --- a/components/ambient-ui/src/adapters/mappers.ts +++ b/components/ambient-ui/src/adapters/mappers.ts @@ -1,5 +1,8 @@ import type { Session, Project } from 'ambient-sdk' -import type { DomainSession, DomainProject, DomainSessionMessage, SessionPhase, SessionEventType } from '@/domain/types' +import type { + DomainSession, DomainProject, DomainSessionMessage, SessionPhase, SessionEventType, + DomainRepo, DomainReconciledRepo, DomainCondition, ReconciledRepoStatus, ConditionStatus, +} from '@/domain/types' const VALID_PHASES: ReadonlySet = new Set([ 'Pending', @@ -37,10 +40,85 @@ function parseAnnotations(raw: string): Record { } } +function parseJsonArray(raw: string): unknown[] { + if (!raw) return [] + try { + const parsed: unknown = JSON.parse(raw) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +function parseJsonObject(raw: string): Record { + if (!raw) return {} + try { + const parsed: unknown = JSON.parse(raw) + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + const result: Record = {} + for (const [key, value] of Object.entries(parsed as Record)) { + result[key] = String(value) + } + return result + } + return {} + } catch { + return {} + } +} + +const VALID_REPO_STATUSES: ReadonlySet = new Set(['Cloning', 'Ready', 'Failed']) +const VALID_CONDITION_STATUSES: ReadonlySet = new Set(['True', 'False', 'Unknown']) + +function parseRepos(raw: string): DomainRepo[] { + return parseJsonArray(raw).map((item) => { + const r = item as Record + return { + 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, + } + }) +} + +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, + } + }) +} + function emptyToNull(value: string): string | null { return value || null } +function numberOrNull(value: number): number | null { + return value === 0 || value === undefined || value === null ? null : value +} + export function mapSdkSessionToDomain(sdk: Session): DomainSession { const annotations = parseAnnotations(sdk.annotations) return { @@ -51,11 +129,22 @@ export function mapSdkSessionToDomain(sdk: Session): DomainSession { agentName: annotations['agent_name'] ?? null, 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), + workflowId: emptyToNull(sdk.workflow_id), + prompt: emptyToNull(sdk.prompt), + sdkRestartCount: sdk.sdk_restart_count ?? 0, startTime: emptyToNull(sdk.start_time), completionTime: emptyToNull(sdk.completion_time), createdAt: sdk.created_at ?? '', updatedAt: sdk.updated_at ?? '', annotations, + labels: parseJsonObject(sdk.labels), + environmentVariables: parseJsonObject(sdk.environment_variables), + repos: parseRepos(sdk.repos), + reconciledRepos: parseReconciledRepos(sdk.reconciled_repos), + conditions: parseConditions(sdk.conditions), } } diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/phase-tab.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/phase-tab.tsx deleted file mode 100644 index 041797ff6..000000000 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/phase-tab.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import type { DomainSession, SessionPhase } from '@/domain/types' -import { cn } from '@/lib/utils' -import { formatAbsoluteTime } from '@/lib/format-timestamp' - -const LIFECYCLE: SessionPhase[] = ['Pending', 'Creating', 'Running'] - -const TERMINAL_ORDER = 4 - -const PHASE_ORDER: Record = { - Pending: 0, Creating: 1, Running: 2, Stopping: 3, - Completed: TERMINAL_ORDER, Failed: TERMINAL_ORDER, Stopped: TERMINAL_ORDER, -} - -export function PhaseTab({ session }: { session: DomainSession }) { - const currentOrder = PHASE_ORDER[session.phase] - - return ( -
- - - Phase Timeline - - -
- {LIFECYCLE.map((phase, i) => { - const order = PHASE_ORDER[phase] - const isCurrent = phase === session.phase - const isPast = order < currentOrder - return ( -
- {i > 0 && ( -
- )} -
-
- - {phase} - -
-
- ) - })} -
-
-
= TERMINAL_ORDER ? 'bg-foreground border-foreground' : 'bg-background border-muted-foreground/40', - )} /> - = TERMINAL_ORDER ? 'font-medium' : 'text-muted-foreground', - )}> - {currentOrder >= TERMINAL_ORDER ? session.phase : 'Terminal'} - -
-
- - - - - - Metadata - - -
- - - - - - -
-
-
- - {Object.keys(session.annotations).length > 0 && ( - - - Annotations - - - - - - Key - Value - - - - {Object.entries(session.annotations).map(([key, value]) => ( - - {key} - {value} - - ))} - -
-
-
- )} -
- ) -} - -function MetaRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { - return ( -
-
{label}
-
{value}
-
- ) -} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/__tests__/fleet-summary.test.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/__tests__/fleet-summary.test.tsx deleted file mode 100644 index 9eb47026e..000000000 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/__tests__/fleet-summary.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { render, screen } from '@testing-library/react' -import { FleetSummary } from '../fleet-summary' -import type { DomainSession, SessionPhase } from '@/domain/types' - -function makeSession(overrides: Partial = {}): DomainSession { - return { - id: 'sess-001', - name: 'test-session', - phase: 'Running', - agentId: null, - agentName: null, - projectId: 'proj-001', - model: null, - startTime: null, - completionTime: null, - createdAt: '2026-01-15T10:00:00Z', - updatedAt: '2026-01-15T10:00:00Z', - annotations: {}, - ...overrides, - } -} - -describe('FleetSummary', () => { - it('shows total session count', () => { - const sessions = [ - makeSession({ id: 'sess-1' }), - makeSession({ id: 'sess-2' }), - makeSession({ id: 'sess-3' }), - ] - - render() - expect(screen.getByText('3 sessions')).toBeInTheDocument() - }) - - it('shows phase counts grouped by phase', () => { - const sessions = [ - makeSession({ id: 'sess-1', phase: 'Running' }), - makeSession({ id: 'sess-2', phase: 'Running' }), - makeSession({ id: 'sess-3', phase: 'Failed' }), - makeSession({ id: 'sess-4', phase: 'Completed' }), - ] - - render() - expect(screen.getByText('4 sessions')).toBeInTheDocument() - expect(screen.getByText('Running')).toBeInTheDocument() - expect(screen.getByText('Failed')).toBeInTheDocument() - expect(screen.getByText('Completed')).toBeInTheDocument() - expect(screen.getByText('2')).toBeInTheDocument() - expect(screen.getAllByText('1')).toHaveLength(2) - }) - - it('does not render phases with zero count', () => { - const sessions = [makeSession({ id: 'sess-1', phase: 'Running' })] - - render() - expect(screen.queryByText('Pending')).not.toBeInTheDocument() - expect(screen.queryByText('Failed')).not.toBeInTheDocument() - expect(screen.queryByText('Stopped')).not.toBeInTheDocument() - }) - - it('handles empty sessions array', () => { - render() - expect(screen.getByText('0 sessions')).toBeInTheDocument() - }) -}) diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/fleet-columns.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/fleet-columns.tsx deleted file mode 100644 index 1107be31f..000000000 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/fleet-columns.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { createColumnHelper } from '@tanstack/react-table' -import { MessageSquare } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip' -import type { DomainSession } from '@/domain/types' -import { formatRelativeTime, formatDuration } from '@/lib/format-timestamp' -import { useChatSidebar } from '@/components/chat-sidebar-context' -import { PhaseBadge } from './phase-badge' - -const COST_ANNOTATION = 'ambient-code.io/cost/estimate' -const col = createColumnHelper() - -function ChatColumnButton({ sessionId }: { sessionId: string }) { - const { openSidebar, openSessionId } = useChatSidebar() - const isActive = openSessionId === sessionId - - return ( - - - - - - {isActive ? 'Chat sidebar is open' : 'Open chat in sidebar'} - - - ) -} - -export const fleetColumns = [ - col.accessor('phase', { - header: 'Phase', - cell: info => , - size: 130, - }), - col.accessor('name', { - header: 'Name', - cell: info => ( - {info.getValue()} - ), - }), - col.accessor('agentName', { - header: 'Agent', - cell: info => { - const name = info.getValue() - const agentId = info.row.original.agentId - return ( - - {name ?? agentId ?? '—'} - - ) - }, - }), - col.display({ - id: 'duration', - header: 'Duration', - cell: ({ row }) => { - const { startTime, completionTime } = row.original - if (!startTime) return - return ( - - {formatDuration(startTime, completionTime)} - - ) - }, - }), - col.accessor('model', { - header: 'Model', - cell: info => ( - - {info.getValue() ?? '—'} - - ), - }), - col.accessor('updatedAt', { - header: 'Last Activity', - cell: info => ( - - {formatRelativeTime(info.getValue())} - - ), - }), - col.display({ - id: 'cost', - header: 'Cost', - cell: ({ row }) => { - const cost = row.original.annotations[COST_ANNOTATION] - return ( - - {cost ?? '—'} - - ) - }, - size: 80, - }), - col.display({ - id: 'chat', - header: '', - cell: ({ row }) => , - size: 48, - }), -] diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/fleet-summary.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/fleet-summary.tsx deleted file mode 100644 index 431ae8bf2..000000000 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/fleet-summary.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { DomainSession, SessionPhase } from '@/domain/types' -import { PhaseBadge } from './phase-badge' - -export function FleetSummary({ sessions }: { sessions: DomainSession[] }) { - const counts = sessions.reduce>>((acc, s) => { - acc[s.phase] = (acc[s.phase] ?? 0) + 1 - return acc - }, {}) - - const phases: SessionPhase[] = ['Running', 'Pending', 'Creating', 'Stopping', 'Failed', 'Completed', 'Stopped'] - - return ( -
- {sessions.length} sessions - - {phases.map(phase => { - const count = counts[phase] - if (!count) return null - return ( -
- - {count} -
- ) - })} -
- ) -} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/fleet-table.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/fleet-table.tsx deleted file mode 100644 index c54843d2b..000000000 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/fleet-table.tsx +++ /dev/null @@ -1,85 +0,0 @@ -'use client' - -import { useRouter, useParams } from 'next/navigation' -import { - useReactTable, - getCoreRowModel, - getFilteredRowModel, - flexRender, -} from '@tanstack/react-table' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { TooltipProvider } from '@/components/ui/tooltip' -import type { DomainSession } from '@/domain/types' -import { fleetColumns } from './fleet-columns' - -export function FleetTable({ - sessions, - searchFilter, -}: { - sessions: DomainSession[] - searchFilter: string -}) { - const router = useRouter() - const { projectId } = useParams<{ projectId: string }>() - - const table = useReactTable({ - data: sessions, - columns: fleetColumns, - getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), - globalFilterFn: 'includesString', - state: { globalFilter: searchFilter }, - }) - - return ( - -
- - - {table.getHeaderGroups().map(headerGroup => ( - - {headerGroup.headers.map(header => ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - {table.getRowModel().rows.length ? ( - table.getRowModel().rows.map(row => ( - router.push(`/${projectId}/fleet/${row.original.id}`)} - > - {row.getVisibleCells().map(cell => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - - No sessions match your filter. - - - )} - -
-
-
- ) -} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/__tests__/config-tab.test.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/__tests__/config-tab.test.tsx new file mode 100644 index 000000000..f8d959e6b --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/__tests__/config-tab.test.tsx @@ -0,0 +1,208 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { ConfigTab } from '../config-tab' +import type { DomainSession } from '@/domain/types' + +function makeSession(overrides: Partial = {}): DomainSession { + return { + id: 'sess-001', + name: 'test-session', + phase: 'Running', + agentId: null, + agentName: null, + projectId: 'proj-001', + model: 'claude-sonnet-4-20250514', + temperature: 0.7, + maxTokens: 4096, + timeout: 3600, + workflowId: null, + prompt: null, + sdkRestartCount: 0, + startTime: null, + completionTime: null, + createdAt: '2026-01-15T10:00:00Z', + updatedAt: '2026-01-15T10:00:00Z', + annotations: {}, + labels: {}, + environmentVariables: {}, + repos: [], + reconciledRepos: [], + conditions: [], + ...overrides, + } +} + +describe('ConfigTab', () => { + it('renders configuration metadata', () => { + render() + expect(screen.getByText('Configuration')).toBeTruthy() + expect(screen.getByText('claude-sonnet-4-20250514')).toBeTruthy() + expect(screen.getByText('0.7')).toBeTruthy() + expect(screen.getByText('4096')).toBeTruthy() + expect(screen.getByText('3600s')).toBeTruthy() + }) + + it('shows dashes for null config values', () => { + render( + , + ) + const dashes = screen.getAllByText('—') + expect(dashes.length).toBeGreaterThanOrEqual(4) + }) + + it('renders environment variables table with count', () => { + render( + , + ) + expect(screen.getByText('Environment Variables (2)')).toBeTruthy() + expect(screen.getByText('NODE_ENV')).toBeTruthy() + expect(screen.getByText('production')).toBeTruthy() + expect(screen.getByText('DEBUG')).toBeTruthy() + }) + + it('hides environment variables section when empty', () => { + render() + expect(screen.queryByText(/Environment Variables/)).toBeNull() + }) + + it('renders annotations with friendly labels for registered keys', () => { + render( + , + ) + expect(screen.getByText('Annotations (2)')).toBeTruthy() + expect(screen.getByText('Jira Issue')).toBeTruthy() + expect(screen.getByText('GitHub PR')).toBeTruthy() + const hyperfleetMatches = screen.getAllByText('HYPERFLEET-234') + expect(hyperfleetMatches.length).toBeGreaterThanOrEqual(1) + const prMatches = screen.getAllByText('org/repo#42') + expect(prMatches.length).toBeGreaterThanOrEqual(1) + }) + + it('renders raw annotation keys when not registered', () => { + render( + , + ) + expect(screen.getByText('Annotations (1)')).toBeTruthy() + expect(screen.getByText('custom-key')).toBeTruthy() + expect(screen.getByText('custom-val')).toBeTruthy() + }) + + it('hides annotations section when no annotations exist', () => { + render() + expect(screen.queryByText(/Annotations/)).toBeNull() + }) + + it('renders labels table with count', () => { + render( + , + ) + expect(screen.getByText('Labels (2)')).toBeTruthy() + expect(screen.getByText('team')).toBeTruthy() + expect(screen.getByText('platform')).toBeTruthy() + }) + + it('hides labels section when empty', () => { + render() + expect(screen.queryByText(/Labels/)).toBeNull() + }) + + it('renders prompt with truncation and char count', () => { + const longPrompt = 'x'.repeat(300) + render() + expect(screen.getByText('Prompt')).toBeTruthy() + expect(screen.getByText('Show more (300 chars)')).toBeTruthy() + }) + + it('expands truncated prompt on click', () => { + const longPrompt = 'A'.repeat(100) + 'B'.repeat(200) + render() + fireEvent.click(screen.getByText('Show more (300 chars)')) + expect(screen.getByText('Show less')).toBeTruthy() + expect(screen.getByText(longPrompt)).toBeTruthy() + }) + + it('renders short prompt without truncation', () => { + render() + expect(screen.getByText('Fix the auth bug')).toBeTruthy() + expect(screen.queryByText(/Show more/)).toBeNull() + }) + + it('renders clickable URL annotation values as links', () => { + render( + , + ) + const link = screen.getByRole('link', { name: 'https://app.example.com' }) + expect(link).toBeTruthy() + expect(link.getAttribute('href')).toBe('https://app.example.com') + expect(link.getAttribute('target')).toBe('_blank') + }) + + it('masks secret-looking env var values', () => { + render( + , + ) + expect(screen.getByText('NODE_ENV')).toBeTruthy() + expect(screen.getByText('production')).toBeTruthy() + expect(screen.getByText('CREDENTIAL_ID')).toBeTruthy() + expect(screen.getByText('••••••••')).toBeTruthy() + expect(screen.queryByText('masked-val')).toBeNull() + }) + + it('reveals secret value on toggle click', () => { + render( + , + ) + expect(screen.getByText('••••••••')).toBeTruthy() + fireEvent.click(screen.getByLabelText('Reveal secret value')) + expect(screen.getByText('revealed-val')).toBeTruthy() + expect(screen.queryByText('••••••••')).toBeNull() + }) + + it('hides Agent Restarts when sdkRestartCount is 0', () => { + render() + expect(screen.queryByText('Agent Restarts')).toBeNull() + }) + + it('shows Agent Restarts when sdkRestartCount > 0', () => { + render() + expect(screen.getByText('Agent Restarts')).toBeTruthy() + expect(screen.getByText('3')).toBeTruthy() + }) + + it('renders Workflow ID with mono styling and tooltip', () => { + render() + const wfElement = screen.getByText('wf-abc-123') + expect(wfElement).toBeTruthy() + expect(wfElement.getAttribute('title')).toBe('Workflow ID') + expect(wfElement.className).toContain('font-mono') + }) +}) diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/event-type-badge.test.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/__tests__/event-type-badge.test.tsx similarity index 100% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/event-type-badge.test.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/__tests__/event-type-badge.test.tsx diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/logs-tab.test.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/__tests__/logs-tab.test.tsx similarity index 96% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/logs-tab.test.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/__tests__/logs-tab.test.tsx index e067bd256..f9409ad5d 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/logs-tab.test.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/__tests__/logs-tab.test.tsx @@ -13,11 +13,22 @@ function makeSession(overrides: Partial = {}): DomainSession { agentName: null, projectId: 'proj-001', model: null, + temperature: null, + maxTokens: null, + timeout: null, + workflowId: null, + prompt: null, + sdkRestartCount: 0, startTime: null, completionTime: null, createdAt: '2026-01-15T10:00:00Z', updatedAt: '2026-01-15T10:00:00Z', annotations: {}, + labels: {}, + environmentVariables: {}, + repos: [], + reconciledRepos: [], + conditions: [], ...overrides, } } diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/__tests__/resources-tab.test.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/__tests__/resources-tab.test.tsx new file mode 100644 index 000000000..625e49b5b --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/__tests__/resources-tab.test.tsx @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { ResourcesTab } from '../resources-tab' +import type { DomainSession, DomainRepo, DomainReconciledRepo } from '@/domain/types' + +function makeSession(overrides: Partial = {}): DomainSession { + return { + id: 'sess-001', + name: 'test-session', + phase: 'Running', + agentId: null, + agentName: null, + projectId: 'proj-001', + model: null, + temperature: null, + maxTokens: null, + timeout: null, + workflowId: null, + prompt: null, + sdkRestartCount: 0, + startTime: null, + completionTime: null, + createdAt: '2026-01-15T10:00:00Z', + updatedAt: '2026-01-15T10:00:00Z', + annotations: {}, + labels: {}, + environmentVariables: {}, + repos: [], + reconciledRepos: [], + conditions: [], + ...overrides, + } +} + +const REPO: DomainRepo = { + url: 'https://github.com/org/platform.git', + branch: 'main', + name: 'platform', + autoPush: false, +} + +const RECONCILED: DomainReconciledRepo = { + url: 'https://github.com/org/platform.git', + name: 'platform', + status: 'Ready', + currentActiveBranch: 'feat/new-feature', + defaultBranch: 'main', + clonedAt: '2026-01-15T10:02:00Z', +} + +describe('ResourcesTab', () => { + it('shows empty state when no repos', () => { + render() + expect(screen.getByText('No resources attached')).toBeTruthy() + expect(screen.getByText('This session has no repositories configured.')).toBeTruthy() + }) + + it('renders repo table with merged data', () => { + render( + , + ) + expect(screen.getByText('platform')).toBeTruthy() + const link = screen.getByRole('link', { name: 'https://github.com/org/platform.git' }) + expect(link).toBeTruthy() + expect(link.getAttribute('href')).toBe('https://github.com/org/platform.git') + expect(link.getAttribute('target')).toBe('_blank') + expect(screen.getByText('feat/new-feature')).toBeTruthy() + expect(screen.getByText('Ready')).toBeTruthy() + }) + + it('shows config branch when no reconciled data', () => { + render() + expect(screen.getByText('main')).toBeTruthy() + }) + + it('renders clone status badges with correct text', () => { + const cloningRepo: DomainReconciledRepo = { ...RECONCILED, status: 'Cloning', clonedAt: null } + render( + , + ) + expect(screen.getByText('Cloning')).toBeTruthy() + }) + + it('renders failed clone status', () => { + const failedRepo: DomainReconciledRepo = { ...RECONCILED, status: 'Failed', clonedAt: null } + render( + , + ) + expect(screen.getByText('Failed')).toBeTruthy() + }) + + it('shows dash for missing clone status', () => { + const noStatusRepo: DomainReconciledRepo = { ...RECONCILED, status: null, clonedAt: null } + render( + , + ) + const cells = screen.getAllByRole('cell') + const statusCell = cells[3] + expect(statusCell.textContent).toBe('—') + }) + + it('shows repository count in card title', () => { + render( + , + ) + expect(screen.getByText(/Repositories \(1\)/)).toBeTruthy() + }) + + it('renders repo URLs as clickable links with title tooltips', () => { + render( + , + ) + const link = screen.getByRole('link', { name: 'https://github.com/org/platform.git' }) + expect(link.getAttribute('title')).toBe('https://github.com/org/platform.git') + }) + + it('derives name from URL basename when no name provided', () => { + const unnamedRepo: DomainRepo = { url: 'https://github.com/org/myrepo.git', branch: null, name: null, autoPush: false } + render() + expect(screen.getByText('myrepo')).toBeTruthy() + }) +}) diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/chat-tab.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/chat-tab.tsx similarity index 100% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/chat-tab.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/chat-tab.tsx diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/config-tab.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/config-tab.tsx new file mode 100644 index 000000000..19b65bc9d --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/config-tab.tsx @@ -0,0 +1,252 @@ +'use client' + +import { useState } from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import type { DomainSession } from '@/domain/types' +import { getRegisteredAnnotation } from '@/domain/annotations' +import type { LucideIcon } from 'lucide-react' +import { + Pin, + Tag, + Ticket, + GitPullRequest, + GitBranch, + FolderGit2, + Layers, + ExternalLink, + MessageCircle, + User, + Play, + DollarSign, + Siren, + Bot, + AlertTriangle, + Eye, + EyeOff, +} from 'lucide-react' +import { MetaRow, NoValue } from './meta-row' + +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 + +const SECRET_PATTERNS = /SECRET|TOKEN|PASSWORD|KEY|API|CREDENTIAL/i + +function isSecretKey(key: string): boolean { + return SECRET_PATTERNS.test(key) +} + +function isClickableValue(value: string): boolean { + return /^https?:\/\//.test(value) +} + +function SecretValue({ value }: { value: string }) { + const [revealed, setRevealed] = useState(false) + + return ( + + + {revealed ? value : '••••••••'} + + + + ) +} + +export function ConfigTab({ session }: { session: DomainSession }) { + const [promptExpanded, setPromptExpanded] = useState(false) + + const envEntries = Object.entries(session.environmentVariables) + const annotationEntries = Object.entries(session.annotations) + const labelEntries = Object.entries(session.labels) + + const promptNeedsTruncation = + session.prompt != null && session.prompt.length > PROMPT_TRUNCATE_LENGTH + const displayPrompt = + session.prompt != null + ? promptNeedsTruncation && !promptExpanded + ? session.prompt.slice(0, PROMPT_TRUNCATE_LENGTH) + '…' + : session.prompt + : null + + return ( +
+ + + Configuration + + +
+ } /> + } /> + } /> + } /> + {session.workflowId} + : + } + /> + {session.sdkRestartCount > 0 && ( + + )} +
+
+
+ + {displayPrompt != null && ( + + + Prompt + + +
{displayPrompt}
+ {promptNeedsTruncation && ( + + )} +
+
+ )} + + {envEntries.length > 0 && ( + + + + Environment Variables ({envEntries.length}) + + + + + + + Key + Value + + + + {envEntries.map(([key, value]) => ( + + {key} + + {isSecretKey(key) ? : value} + + + ))} + +
+
+
+ )} + + {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}) + + + + + + + Key + Value + + + + {labelEntries.map(([key, value]) => ( + + {key} + {value} + + ))} + +
+
+
+ )} +
+ ) +} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/event-announcer.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/event-announcer.tsx similarity index 100% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/event-announcer.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/event-announcer.tsx diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/event-row.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/event-row.tsx similarity index 100% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/event-row.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/event-row.tsx diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/event-summary-banner.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/event-summary-banner.tsx similarity index 100% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/event-summary-banner.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/event-summary-banner.tsx diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/event-type-badge.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/event-type-badge.tsx similarity index 100% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/event-type-badge.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/event-type-badge.tsx diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/live-tail-indicator.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/live-tail-indicator.tsx similarity index 100% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/live-tail-indicator.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/live-tail-indicator.tsx diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/logs-tab.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/logs-tab.tsx similarity index 100% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/logs-tab.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/logs-tab.tsx diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/meta-row.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/meta-row.tsx new file mode 100644 index 000000000..e45a22efa --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/meta-row.tsx @@ -0,0 +1,16 @@ +import { cn } from '@/lib/utils' + +export function MetaRow({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) { + return ( +
+
{label}
+
+ {value ?? } +
+
+ ) +} + +export function NoValue() { + return +} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/overview-tab.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/overview-tab.tsx new file mode 100644 index 000000000..1aed9ea28 --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/overview-tab.tsx @@ -0,0 +1,103 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import type { DomainSession, SessionPhase } from '@/domain/types' +import { cn } from '@/lib/utils' +import { formatAbsoluteTime } from '@/lib/format-timestamp' +import { MetaRow, NoValue } from './meta-row' + +const LIFECYCLE: SessionPhase[] = ['Pending', 'Creating', 'Running'] + +const TERMINAL_ORDER = 4 + +const PHASE_ORDER: Record = { + Pending: 0, Creating: 1, Running: 2, Stopping: 3, + Completed: TERMINAL_ORDER, Failed: TERMINAL_ORDER, Stopped: TERMINAL_ORDER, +} + +function phaseColor(phase: SessionPhase): string { + switch (phase) { + case 'Running': + return 'bg-green-500 border-green-500' + case 'Failed': + return 'bg-red-500 border-red-500' + case 'Completed': + return 'bg-blue-500 border-blue-500' + case 'Stopped': + return 'bg-muted-foreground border-muted-foreground' + default: + return 'bg-foreground border-foreground' + } +} + +function TimelineSteps({ session, currentOrder }: { session: DomainSession; currentOrder: number }) { + const terminalLabel = currentOrder >= TERMINAL_ORDER ? session.phase : 'Terminal' + const terminalActive = currentOrder >= TERMINAL_ORDER + const steps = [ + ...LIFECYCLE.map((phase) => ({ + label: phase, + isCurrent: phase === session.phase, + isPast: PHASE_ORDER[phase] < currentOrder, + })), + { label: terminalLabel, isCurrent: terminalActive, isPast: false }, + ] + + return ( +
+ {steps.map((step, i) => ( +
+ {i > 0 && ( +
+ )} +
+
+ + {step.label} + +
+
+ ))} +
+ ) +} + +export function OverviewTab({ session }: { session: DomainSession }) { + const currentOrder = PHASE_ORDER[session.phase] + + return ( +
+ + + Phase Timeline + + + + + + + + + Timing + + +
+ + } /> + } /> + } /> + } /> +
+
+
+
+ ) +} 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 new file mode 100644 index 000000000..30cb4db5a --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/resources-tab.tsx @@ -0,0 +1,152 @@ +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { EmptyState } from '@/components/empty-state' +import type { DomainSession, DomainRepo, DomainReconciledRepo, ReconciledRepoStatus } from '@/domain/types' +import { formatAbsoluteTime } from '@/lib/format-timestamp' +import { cn } from '@/lib/utils' +import { FolderGit2 } from 'lucide-react' +import { NoValue } from './meta-row' + +const STATUS_CLASSES: Record = { + Ready: 'bg-status-success text-status-success-foreground border-status-success-border', + Cloning: 'bg-status-warning text-status-warning-foreground border-status-warning-border', + Failed: 'bg-status-error text-status-error-foreground border-status-error-border', +} + +type MergedRepo = { + url: string + name: string + branch: string | null + status: ReconciledRepoStatus | null + clonedAt: string | null +} + +function mergeRepos( + repos: DomainRepo[], + reconciledRepos: DomainReconciledRepo[], +): MergedRepo[] { + const reconciledByUrl = new Map( + reconciledRepos.map(r => [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) + result.push({ + url: repo.url, + name: reconciled?.name ?? repo.name ?? baseNameFromUrl(repo.url), + branch: reconciled?.currentActiveBranch ?? repo.branch ?? null, + status: reconciled?.status ?? null, + clonedAt: reconciled?.clonedAt ?? null, + }) + } + + for (const reconciled of reconciledRepos) { + if (!seen.has(reconciled.url)) { + result.push({ + url: reconciled.url, + name: reconciled.name ?? baseNameFromUrl(reconciled.url), + branch: reconciled.currentActiveBranch ?? null, + status: reconciled.status, + clonedAt: reconciled.clonedAt, + }) + } + } + + return result +} + +function baseNameFromUrl(url: string): string { + const segments = url.replace(/\.git$/, '').split('/') + return segments[segments.length - 1] || url +} + +export function ResourcesTab({ session }: { session: DomainSession }) { + const merged = mergeRepos(session.repos, session.reconciledRepos) + const hasRepos = merged.length > 0 + + if (!hasRepos) { + return ( +
+ +
+ ) + } + + return ( +
+ + + + + Repositories ({merged.length}) + + + + + + + Name + URL + Branch + Clone Status + Cloned At + + + + {merged.map(repo => ( + + {repo.name} + + + {repo.url} + + + + {repo.branch ?? } + + + {repo.status ? ( + + {repo.status} + + ) : ( + + )} + + + {repo.clonedAt ? formatAbsoluteTime(repo.clonedAt) : } + + + ))} + +
+
+
+
+ ) +} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/session-header.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/session-header.tsx similarity index 93% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/session-header.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/session-header.tsx index 86f8d2e1e..ff18227de 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/session-header.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/session-header.tsx @@ -59,7 +59,7 @@ export function SessionHeader({ session }: { session: DomainSession }) { deleteSession.mutate(session.id, { onSuccess: () => { setDeleteDialogOpen(false) - router.push(`/${projectId}/fleet`) + router.push(`/${projectId}/sessions`) }, onError: () => setDeleteDialogOpen(false), }) @@ -100,11 +100,11 @@ export function SessionHeader({ session }: { session: DomainSession }) { return ( <> -
+
-

{session.name}

+

{session.name}

@@ -116,7 +116,7 @@ export function SessionHeader({ session }: { session: DomainSession }) { onClick={() => setPreviewOpen(true)} aria-label="Open preview" > - + Preview )} @@ -129,7 +129,7 @@ export function SessionHeader({ session }: { session: DomainSession }) { disabled={stopSession.isPending} aria-label="Stop session" > - + Stop )} @@ -142,20 +142,20 @@ export function SessionHeader({ session }: { session: DomainSession }) { disabled={startSession.isPending} aria-label="Restart session" > - + Restart )} - - + Export @@ -164,7 +164,7 @@ export function SessionHeader({ session }: { session: DomainSession }) { disabled={deleteSession.isPending} className="text-destructive focus:text-destructive" > - + Delete diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/page.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/page.tsx similarity index 58% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/page.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/page.tsx index 1d85f3ee9..251d3c73a 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/page.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/page.tsx @@ -5,16 +5,25 @@ import { useParams } from 'next/navigation' import { Skeleton } from '@/components/ui/skeleton' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useSession } from '@/queries/use-sessions' +import { + LayoutDashboard, + ScrollText, + FolderGit2, + Settings, + MessageSquare, +} from 'lucide-react' import { SessionHeader } from './_components/session-header' -import { PhaseTab } from './_components/phase-tab' +import { OverviewTab } from './_components/overview-tab' import { LogsTab } from './_components/logs-tab' import { ChatTab } from './_components/chat-tab' +import { ResourcesTab } from './_components/resources-tab' +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 'phase' - return new URL(window.location.href).searchParams.get('tab') ?? 'phase' + if (typeof window === 'undefined') return 'overview' + return new URL(window.location.href).searchParams.get('tab') ?? 'overview' }) const { data: session, isLoading, error } = useSession(sessionId) @@ -47,18 +56,34 @@ export default function SessionDetailPage() { - Phase - Logs - Resources - Details - Chat + + Overview + + + Logs + + + Resources + + + Config + + + Chat + - - + + + + + + + + diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/__tests__/fleet-summary.test.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/__tests__/fleet-summary.test.tsx new file mode 100644 index 000000000..bdddba212 --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/__tests__/fleet-summary.test.tsx @@ -0,0 +1,160 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { FleetSummary } from '../fleet-summary' +import type { DomainSession, SessionPhase } from '@/domain/types' + +function makeSession(overrides: Partial = {}): DomainSession { + return { + id: 'sess-001', + name: 'test-session', + phase: 'Running', + agentId: null, + agentName: null, + projectId: 'proj-001', + model: null, + temperature: null, + maxTokens: null, + timeout: null, + workflowId: null, + prompt: null, + sdkRestartCount: 0, + startTime: null, + completionTime: null, + createdAt: '2026-01-15T10:00:00Z', + updatedAt: '2026-01-15T10:00:00Z', + annotations: {}, + labels: {}, + environmentVariables: {}, + repos: [], + reconciledRepos: [], + conditions: [], + ...overrides, + } +} + +describe('FleetSummary', () => { + it('shows total session count', () => { + const sessions = [ + makeSession({ id: 'sess-1' }), + makeSession({ id: 'sess-2' }), + makeSession({ id: 'sess-3' }), + ] + + render() + expect(screen.getByText('3 sessions')).toBeInTheDocument() + }) + + it('shows phase counts grouped by phase', () => { + const sessions = [ + makeSession({ id: 'sess-1', phase: 'Running' }), + makeSession({ id: 'sess-2', phase: 'Running' }), + makeSession({ id: 'sess-3', phase: 'Failed' }), + makeSession({ id: 'sess-4', phase: 'Completed' }), + ] + + render() + expect(screen.getByText('4 sessions')).toBeInTheDocument() + expect(screen.getByText('Running')).toBeInTheDocument() + expect(screen.getByText('Failed')).toBeInTheDocument() + expect(screen.getByText('Completed')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + expect(screen.getAllByText('1')).toHaveLength(2) + }) + + it('does not render phases with zero count', () => { + const sessions = [makeSession({ id: 'sess-1', phase: 'Running' })] + + render() + expect(screen.queryByText('Pending')).not.toBeInTheDocument() + expect(screen.queryByText('Failed')).not.toBeInTheDocument() + expect(screen.queryByText('Stopped')).not.toBeInTheDocument() + }) + + it('handles empty sessions array', () => { + render() + expect(screen.getByText('0 sessions')).toBeInTheDocument() + }) + + it('shows filtered count when filteredCount differs from total', () => { + const sessions = [ + makeSession({ id: 'sess-1' }), + makeSession({ id: 'sess-2' }), + makeSession({ id: 'sess-3' }), + ] + + render() + expect(screen.getByText('Showing 2 of 3 sessions')).toBeInTheDocument() + }) + + it('shows normal count when filteredCount equals total', () => { + const sessions = [ + makeSession({ id: 'sess-1' }), + makeSession({ id: 'sess-2' }), + ] + + render() + expect(screen.getByText('2 sessions')).toBeInTheDocument() + }) + + it('calls onPhaseFilter when a phase chip is clicked', () => { + const onPhaseFilter = vi.fn() + const sessions = [ + makeSession({ id: 'sess-1', phase: 'Running' }), + makeSession({ id: 'sess-2', phase: 'Failed' }), + ] + + render( + + ) + + const runningButton = screen.getByRole('button', { name: 'Filter by Running' }) + fireEvent.click(runningButton) + expect(onPhaseFilter).toHaveBeenCalledWith('Running') + }) + + it('clears phase filter when active phase chip is clicked', () => { + const onPhaseFilter = vi.fn() + const sessions = [ + makeSession({ id: 'sess-1', phase: 'Running' }), + ] + + render( + + ) + + const runningButton = screen.getByRole('button', { name: 'Filter by Running' }) + fireEvent.click(runningButton) + expect(onPhaseFilter).toHaveBeenCalledWith(null) + }) + + it('renders phase chips as buttons when onPhaseFilter is provided', () => { + const sessions = [ + makeSession({ id: 'sess-1', phase: 'Running' }), + ] + + render( + {}} + /> + ) + + expect(screen.getByRole('button', { name: 'Filter by Running' })).toBeInTheDocument() + }) + + it('renders phase chips as non-interactive when onPhaseFilter is not provided', () => { + const sessions = [ + makeSession({ id: 'sess-1', phase: 'Running' }), + ] + + render() + expect(screen.queryByRole('button', { name: 'Filter by Running' })).not.toBeInTheDocument() + }) +}) diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/__tests__/phase-badge.test.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/__tests__/phase-badge.test.tsx similarity index 100% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/__tests__/phase-badge.test.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/__tests__/phase-badge.test.tsx diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/fleet-columns.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/fleet-columns.tsx new file mode 100644 index 000000000..8abb553b5 --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/fleet-columns.tsx @@ -0,0 +1,215 @@ +import { createColumnHelper } from '@tanstack/react-table' +import type { SortingFn } from '@tanstack/react-table' +import { MessageSquare } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import type { DomainSession, SessionPhase } from '@/domain/types' +import { formatRelativeTime, formatPreciseDuration } from '@/lib/format-timestamp' +import { useChatSidebar } from '@/components/chat-sidebar-context' +import { PhaseBadge } from './phase-badge' + +const COST_ANNOTATION = 'ambient-code.io/cost/estimate' +const col = createColumnHelper() + +const RUNNING_PHASES: ReadonlySet = new Set(['Running', 'Creating', 'Pending', 'Stopping']) +const TERMINAL_PHASES: ReadonlySet = new Set(['Completed', 'Failed', 'Stopped']) + +/** Priority order for phase sorting: Failed first (0), terminal last */ +const PHASE_SORT_PRIORITY: Record = { + Failed: 0, + Running: 1, + Stopping: 2, + Creating: 3, + Pending: 4, + Completed: 5, + Stopped: 6, +} + +const phaseSortingFn: SortingFn = (rowA, rowB) => { + const a = PHASE_SORT_PRIORITY[rowA.original.phase] ?? 99 + const b = PHASE_SORT_PRIORITY[rowB.original.phase] ?? 99 + return a - b +} + +function ChatColumnButton({ sessionId, phase }: { sessionId: string; phase: SessionPhase }) { + const { openSidebar, openSessionId } = useChatSidebar() + const isActive = openSessionId === sessionId + const isTerminal = TERMINAL_PHASES.has(phase) + + return ( + + + + + + {isActive + ? 'Chat sidebar is open' + : isTerminal + ? 'View chat history' + : 'Open chat in sidebar'} + + + ) +} + +export const fleetColumns = [ + col.accessor('phase', { + header: 'Phase', + cell: info => , + size: 130, + enableSorting: true, + sortingFn: phaseSortingFn, + }), + col.accessor('name', { + header: 'Name', + cell: info => ( + {info.getValue()} + ), + }), + col.accessor('agentId', { + header: 'Agent', + cell: info => { + const agentId = info.getValue() + const annotationName = info.row.original.agentName + const agentNames = (info.table.options.meta as { agentNames?: Map } | undefined)?.agentNames + const resolvedName = annotationName ?? (agentId ? agentNames?.get(agentId) : null) ?? null + if (!resolvedName) return + return ( + + {resolvedName} + + ) + }, + }), + col.display({ + id: 'duration', + header: 'Duration', + enableSorting: true, + sortingFn: (rowA, rowB) => { + const getMs = (row: typeof rowA) => { + const { startTime, completionTime, phase } = row.original + if (!startTime) return 0 + const isActive = RUNNING_PHASES.has(phase) + const end = isActive ? new Date() : (completionTime ? new Date(completionTime) : new Date()) + return Math.max(0, end.getTime() - new Date(startTime).getTime()) + } + return getMs(rowA) - getMs(rowB) + }, + cell: ({ row }) => { + const { startTime, completionTime, phase } = row.original + if (!startTime) return + const isActive = RUNNING_PHASES.has(phase) + const endTime = isActive ? null + : (completionTime && new Date(completionTime) > new Date(startTime)) ? completionTime + : null + return ( + + {formatPreciseDuration(startTime, endTime)} + + ) + }, + }), + col.accessor('model', { + header: 'Model', + cell: info => ( + + {info.getValue() ?? '—'} + + ), + }), + col.display({ + id: 'lastActivity', + header: 'Last Activity', + enableSorting: true, + sortingFn: (rowA, rowB) => { + const getTime = (row: typeof rowA) => { + const { phase, completionTime, updatedAt } = row.original + if (RUNNING_PHASES.has(phase)) return Date.now() + return new Date(completionTime ?? updatedAt).getTime() + } + return getTime(rowA) - getTime(rowB) + }, + cell: ({ row }) => { + const { phase, completionTime, updatedAt } = row.original + + if (phase === 'Running') { + return ( + + Active now + + ) + } + + if (phase === 'Creating' || phase === 'Pending') { + return ( + + Starting... + + ) + } + + if (phase === 'Stopping') { + return ( + + Stopping... + + ) + } + + const activityTime = completionTime ?? updatedAt + return ( + + {formatRelativeTime(activityTime)} + + ) + }, + }), + col.display({ + id: 'cost', + header: 'Cost', + enableSorting: true, + sortingFn: (rowA, rowB) => { + const getCost = (row: typeof rowA) => { + const raw = row.original.annotations[COST_ANNOTATION] + if (!raw) return 0 + return parseFloat(raw.replace(/[^0-9.]/g, '')) || 0 + } + return getCost(rowA) - getCost(rowB) + }, + cell: ({ row }) => { + const cost = row.original.annotations[COST_ANNOTATION] + return ( + + {cost ?? '—'} + + ) + }, + size: 80, + }), + col.display({ + id: 'chat', + header: () => ( + + ), + cell: ({ row }) => , + size: 48, + enableSorting: false, + }), +] diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/fleet-summary.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/fleet-summary.tsx new file mode 100644 index 000000000..87752b5f9 --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/fleet-summary.tsx @@ -0,0 +1,69 @@ +import { cn } from '@/lib/utils' +import type { DomainSession, SessionPhase } from '@/domain/types' +import { PhaseBadge } from './phase-badge' + +export function FleetSummary({ + sessions, + filteredCount, + activePhase, + onPhaseFilter, +}: { + sessions: DomainSession[] + filteredCount?: number + activePhase?: SessionPhase | null + onPhaseFilter?: (phase: SessionPhase | null) => void +}) { + const counts = sessions.reduce>>((acc, s) => { + acc[s.phase] = (acc[s.phase] ?? 0) + 1 + return acc + }, {}) + + const total = sessions.length + const showFiltered = filteredCount !== undefined && filteredCount !== total + + const phases: SessionPhase[] = ['Running', 'Pending', 'Creating', 'Stopping', 'Failed', 'Completed', 'Stopped'] + + return ( +
+ + {showFiltered + ? `Showing ${filteredCount} of ${total} sessions` + : `${total} sessions`} + + + {phases.map(phase => { + const count = counts[phase] + if (!count) return null + const isActive = activePhase === phase + + if (onPhaseFilter) { + return ( + + ) + } + + return ( +
+ + {count} +
+ ) + })} +
+ ) +} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/fleet-table.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/fleet-table.tsx new file mode 100644 index 000000000..0fa6eddfa --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/fleet-table.tsx @@ -0,0 +1,166 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter, useParams } from 'next/navigation' +import { + useReactTable, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + flexRender, +} from '@tanstack/react-table' +import type { SortingState, ColumnFiltersState } from '@tanstack/react-table' +import { ChevronUp, ChevronDown } from 'lucide-react' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { DomainSession, SessionPhase } from '@/domain/types' +import { fleetColumns } from './fleet-columns' + +export function FleetTable({ + sessions, + searchFilter, + agentNames, + phaseFilter, + onFilteredCountChange, +}: { + sessions: DomainSession[] + searchFilter: string + agentNames?: Map + phaseFilter?: SessionPhase | null + onFilteredCountChange?: (count: number) => void +}) { + const router = useRouter() + const { projectId } = useParams<{ projectId: string }>() + + const [sorting, setSorting] = useState([ + { id: 'phase', desc: false }, + { id: 'lastActivity', desc: true }, + ]) + + const [columnFilters, setColumnFilters] = useState([]) + + // Sync phaseFilter prop to column filters + useEffect(() => { + setColumnFilters(prev => { + const without = prev.filter(f => f.id !== 'phase') + if (phaseFilter) { + return [...without, { id: 'phase', value: phaseFilter }] + } + return without + }) + }, [phaseFilter]) + + const table = useReactTable({ + data: sessions, + columns: fleetColumns, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + globalFilterFn: 'includesString', + state: { + globalFilter: searchFilter, + sorting, + columnFilters, + }, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + meta: { agentNames }, + filterFns: { + phaseEquals: (row, columnId, filterValue) => { + return row.getValue(columnId) === filterValue + }, + }, + }) + + // Report filtered count back to parent + const filteredRowCount = table.getFilteredRowModel().rows.length + useEffect(() => { + onFilteredCountChange?.(filteredRowCount) + }, [filteredRowCount, onFilteredCountChange]) + + return ( + +
+ + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => { + const canSort = header.column.getCanSort() + const sorted = header.column.getIsSorted() + const isChat = header.column.id === 'chat' + + 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 => ( + router.push(`/${projectId}/sessions/${row.original.id}`)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + router.push(`/${projectId}/sessions/${row.original.id}`) + } + }} + > + {row.getVisibleCells().map(cell => { + const isChat = cell.column.id === 'chat' + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} + + )) + ) : ( + + + No sessions match your filter. + + + )} + +
+
+
+ ) +} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/phase-badge.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/phase-badge.tsx similarity index 81% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/phase-badge.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/phase-badge.tsx index fa5dd6e6e..6335ea2e7 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/phase-badge.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/phase-badge.tsx @@ -17,12 +17,12 @@ export function PhaseBadge({ phase }: { phase: SessionPhase }) { return ( {style.pulse && ( - + - + )} {style.label} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/page.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/page.tsx similarity index 58% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/page.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/page.tsx index 2c46b15f1..49efae896 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/page.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/page.tsx @@ -1,24 +1,33 @@ 'use client' -import { useState } from 'react' +import { useState, useCallback } from 'react' import { useParams } from 'next/navigation' import { Monitor } from 'lucide-react' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { EmptyState } from '@/components/empty-state' import { useSessions } from '@/queries/use-sessions' +import { useAgentNames } from '@/queries/use-agents' +import type { SessionPhase } from '@/domain/types' import { FleetTable } from './_components/fleet-table' import { FleetSummary } from './_components/fleet-summary' export default function FleetPage() { const { projectId } = useParams<{ projectId: string }>() const [search, setSearch] = useState('') + const [phaseFilter, setPhaseFilter] = useState(null) + const [filteredCount, setFilteredCount] = useState(undefined) const { data, isLoading, error } = useSessions(projectId) + const { data: agentNames } = useAgentNames(projectId) + + const handleFilteredCountChange = useCallback((count: number) => { + setFilteredCount(count) + }, []) if (error) { return (
-

Fleet

+

Sessions

Failed to load sessions: {error.message}

@@ -29,7 +38,7 @@ export default function FleetPage() { if (isLoading) { return (
-

Fleet

+

Sessions

@@ -43,7 +52,7 @@ export default function FleetPage() { if (sessions.length === 0) { return (
-

Fleet

+

Sessions

-

Fleet

+

Sessions

setSearch(e.target.value)} className="max-w-xs" />
- - + +
) } diff --git a/components/ambient-ui/src/app/(dashboard)/layout.tsx b/components/ambient-ui/src/app/(dashboard)/layout.tsx index c5dc176b9..c6609cc84 100644 --- a/components/ambient-ui/src/app/(dashboard)/layout.tsx +++ b/components/ambient-ui/src/app/(dashboard)/layout.tsx @@ -7,6 +7,7 @@ import { StatusBar } from '@/components/status-bar' import { ChatSidebar } from '@/components/chat-sidebar' import { ChatSidebarProvider } from '@/components/chat-sidebar-context' import { useProject } from '@/queries/use-projects' +import { useSession } from '@/queries/use-sessions' import { SidebarInset, SidebarProvider, @@ -16,7 +17,8 @@ function extractNavContext(pathname: string) { const segments = pathname.split('/').filter(Boolean) const projectId = segments.length >= 1 ? segments[0] : null const pageName = segments.length >= 2 ? capitalize(segments[1]) : null - return { projectId, pageName } + const sessionId = segments.length >= 3 && segments[1] === 'sessions' ? segments[2] : null + return { projectId, pageName, sessionId } } function capitalize(s: string): string { @@ -29,8 +31,9 @@ export default function DashboardLayout({ children: React.ReactNode }) { const pathname = usePathname() - const { projectId, pageName } = extractNavContext(pathname) + const { projectId, pageName, sessionId } = extractNavContext(pathname) const { data: project } = useProject(projectId ?? '') + const { data: session } = useSession(sessionId ?? '', undefined) return ( @@ -41,6 +44,7 @@ export default function DashboardLayout({ projectId={projectId} projectName={project?.name ?? null} pageName={pageName} + sessionName={sessionId ? (session?.name ?? sessionId) : null} />
{children}
diff --git a/components/ambient-ui/src/app/(dashboard)/page.tsx b/components/ambient-ui/src/app/(dashboard)/page.tsx index ec6a00909..10d2bffdb 100644 --- a/components/ambient-ui/src/app/(dashboard)/page.tsx +++ b/components/ambient-ui/src/app/(dashboard)/page.tsx @@ -74,13 +74,13 @@ export default function ProjectPickerPage() { router.push(`/${project.id}/fleet`)} + onClick={() => router.push(`/${project.id}/sessions`)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() - router.push(`/${project.id}/fleet`) + router.push(`/${project.id}/sessions`) } }} > diff --git a/components/ambient-ui/src/components/app-sidebar.tsx b/components/ambient-ui/src/components/app-sidebar.tsx index d9fe5f81d..1e8d5e4da 100644 --- a/components/ambient-ui/src/components/app-sidebar.tsx +++ b/components/ambient-ui/src/components/app-sidebar.tsx @@ -6,10 +6,6 @@ import { useTheme } from 'next-themes' import { Monitor, Bot, - Calendar, - AlertCircle, - Settings, - Key, Moon, Sun, } from 'lucide-react' @@ -19,35 +15,21 @@ import { Sidebar, SidebarContent, SidebarFooter, + SidebarHeader, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, - SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, - SidebarSeparator, } from '@/components/ui/sidebar' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip' type AppSidebarProps = { projectId: string | null } const projectNavItems = [ - { label: 'Fleet', icon: Monitor, href: 'fleet', disabled: false }, - { label: 'Agents', icon: Bot, href: 'agents', disabled: true, tooltip: 'Coming soon' }, - { label: 'Schedules', icon: Calendar, href: 'schedules', disabled: true, tooltip: 'Coming soon' }, - { label: 'Issues', icon: AlertCircle, href: 'issues', disabled: true, tooltip: 'Coming soon' }, - { label: 'Settings', icon: Settings, href: 'settings', disabled: true, tooltip: 'Coming soon' }, -] as const - -const globalNavItems = [ - { label: 'Credentials', icon: Key, href: '/credentials', disabled: true, tooltip: 'Coming soon' }, + { label: 'Sessions', icon: Monitor, href: 'sessions' }, ] as const export function AppSidebar({ projectId }: AppSidebarProps) { @@ -57,6 +39,10 @@ export function AppSidebar({ projectId }: AppSidebarProps) { return ( +
+ + Ambient +
@@ -67,10 +53,10 @@ export function AppSidebar({ projectId }: AppSidebarProps) { {projectNavItems.map((item) => { const href = projectId ? `/${projectId}/${item.href}` : '#' - const isActive = pathname === href - const isDisabled = item.disabled || !projectId + const isActive = pathname === href || pathname.startsWith(href + '/') + const isDisabled = !projectId - const menuButton = ( + return ( ) - - if (isDisabled && 'tooltip' in item && item.tooltip) { - return ( - - - {menuButton} - - - {item.tooltip} - - - ) - } - - return menuButton })} - - - - - Global - - - {globalNavItems.map((item) => ( - - - - - - {item.label} - - - - - {item.tooltip} - - - ))} - - - - +
+ Theme + +
) diff --git a/components/ambient-ui/src/components/chat-sidebar.tsx b/components/ambient-ui/src/components/chat-sidebar.tsx index 533753366..f9cd204fa 100644 --- a/components/ambient-ui/src/components/chat-sidebar.tsx +++ b/components/ambient-ui/src/components/chat-sidebar.tsx @@ -14,7 +14,7 @@ import { } from '@/components/chat-messages' import { useSession } from '@/queries/use-sessions' import { useSessionMessages } from '@/queries/use-session-messages' -import { useLiveTail, LiveIndicator } from '@/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/live-tail-indicator' +import { useLiveTail, LiveIndicator } from '@/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/live-tail-indicator' const MIN_WIDTH = 320 const MAX_WIDTH = 800 @@ -196,7 +196,7 @@ export function ChatSidebar() { onClick={() => { const projectId = session?.projectId if (projectId && openSessionId) { - router.push(`/${projectId}/fleet/${openSessionId}`) + router.push(`/${projectId}/sessions/${openSessionId}`) } }} title="Go to session detail" diff --git a/components/ambient-ui/src/components/empty-state.tsx b/components/ambient-ui/src/components/empty-state.tsx index 6cca7c5ac..0f070d52e 100644 --- a/components/ambient-ui/src/components/empty-state.tsx +++ b/components/ambient-ui/src/components/empty-state.tsx @@ -14,7 +14,7 @@ export function EmptyState({ icon: Icon, title, description, action }: EmptyStat
-

{title}

+

{title}

{description}

{action &&
{action}
} diff --git a/components/ambient-ui/src/components/nav-header.tsx b/components/ambient-ui/src/components/nav-header.tsx index f9ae603fa..7bf7b7a6b 100644 --- a/components/ambient-ui/src/components/nav-header.tsx +++ b/components/ambient-ui/src/components/nav-header.tsx @@ -30,6 +30,12 @@ type NavHeaderProps = { sessionName?: string | null } +const BREADCRUMB_LABEL_MAP: Record = {} + +function displayLabel(raw: string): string { + return BREADCRUMB_LABEL_MAP[raw] ?? raw +} + function UserMenu() { const { user, isLoading } = useCurrentUser() @@ -69,17 +75,19 @@ function UserMenu() { } export function NavHeader({ projectId, projectName, pageName, sessionName }: NavHeaderProps) { + const mappedPageName = pageName ? displayLabel(pageName) : null + return (
- + - Ambient + Ambient @@ -89,22 +97,22 @@ export function NavHeader({ projectId, projectName, pageName, sessionName }: Nav - {projectName ?? projectId} + {projectName ?? projectId} )} - {pageName && ( + {mappedPageName && ( <> {sessionName ? ( - {pageName} + {mappedPageName} ) : ( - {pageName} + {mappedPageName} )} diff --git a/components/ambient-ui/src/components/project-selector.tsx b/components/ambient-ui/src/components/project-selector.tsx index 1924b7022..2d6a0d9fb 100644 --- a/components/ambient-ui/src/components/project-selector.tsx +++ b/components/ambient-ui/src/components/project-selector.tsx @@ -31,7 +31,7 @@ export function ProjectSelector({ projectId }: ProjectSelectorProps) { value={projectId ?? undefined} onValueChange={(value) => { domainProbe.projectSelected({ projectId: value }) - router.push(`/${value}/fleet`) + router.push(`/${value}/sessions`) }} > diff --git a/components/ambient-ui/src/components/ui/table.tsx b/components/ambient-ui/src/components/ui/table.tsx index e2185e5d1..ec5848718 100644 --- a/components/ambient-ui/src/components/ui/table.tsx +++ b/components/ambient-ui/src/components/ui/table.tsx @@ -55,7 +55,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) { ) { [role=checkbox]]:translate-y-[2px]", + "text-muted-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-xs [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", + "data-[sticky=right]:sticky data-[sticky=right]:right-0 data-[sticky=right]:z-10 data-[sticky=right]:bg-muted/30 data-[sticky=right]:shadow-[-2px_0_4px_-2px_rgba(0,0,0,0.1)]", className )} {...props} @@ -82,6 +83,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) { data-slot="table-cell" className={cn( "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", + "data-[sticky=right]:sticky data-[sticky=right]:right-0 data-[sticky=right]:z-10 data-[sticky=right]:bg-background data-[sticky=right]:shadow-[-2px_0_4px_-2px_rgba(0,0,0,0.1)]", className )} {...props} diff --git a/components/ambient-ui/src/domain/types.ts b/components/ambient-ui/src/domain/types.ts index bdd413482..a2fdea308 100644 --- a/components/ambient-ui/src/domain/types.ts +++ b/components/ambient-ui/src/domain/types.ts @@ -7,6 +7,34 @@ export type SessionPhase = | 'Failed' | 'Stopped' +export type DomainRepo = { + url: string + branch: string | null + name: string | null + autoPush: boolean +} + +export type ReconciledRepoStatus = 'Cloning' | 'Ready' | 'Failed' + +export type DomainReconciledRepo = { + url: string + name: string | null + status: ReconciledRepoStatus | null + currentActiveBranch: string | null + defaultBranch: string | null + clonedAt: string | null +} + +export type ConditionStatus = 'True' | 'False' | 'Unknown' + +export type DomainCondition = { + type: string + status: ConditionStatus + reason: string | null + message: string | null + lastTransitionTime: string | null +} + export type DomainSession = { id: string name: string @@ -15,11 +43,22 @@ export type DomainSession = { agentName: string | null projectId: string | null model: string | null + temperature: number | null + maxTokens: number | null + timeout: number | null + workflowId: string | null + prompt: string | null + sdkRestartCount: number startTime: string | null completionTime: string | null createdAt: string updatedAt: string annotations: Record + labels: Record + environmentVariables: Record + repos: DomainRepo[] + reconciledRepos: DomainReconciledRepo[] + conditions: DomainCondition[] } export type DomainProject = { diff --git a/components/ambient-ui/src/lib/__tests__/format-timestamp.test.ts b/components/ambient-ui/src/lib/__tests__/format-timestamp.test.ts index 678da4af1..161561ce2 100644 --- a/components/ambient-ui/src/lib/__tests__/format-timestamp.test.ts +++ b/components/ambient-ui/src/lib/__tests__/format-timestamp.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { formatRelativeTime, formatAbsoluteTime, formatDuration } from '../format-timestamp' +import { formatRelativeTime, formatAbsoluteTime, formatDuration, formatPreciseDuration } from '../format-timestamp' describe('formatRelativeTime', () => { it('returns a human-readable relative time string', () => { @@ -32,3 +32,46 @@ describe('formatDuration', () => { expect(result).toBeTruthy() }) }) + +describe('formatPreciseDuration', () => { + it('formats seconds only', () => { + const start = '2026-05-28T10:00:00Z' + const end = '2026-05-28T10:00:45Z' + expect(formatPreciseDuration(start, end)).toBe('45s') + }) + + it('formats minutes and seconds', () => { + const start = '2026-05-28T10:00:00Z' + const end = '2026-05-28T10:05:30Z' + expect(formatPreciseDuration(start, end)).toBe('5m 30s') + }) + + it('formats hours and minutes', () => { + const start = '2026-05-28T10:00:00Z' + const end = '2026-05-28T12:03:00Z' + expect(formatPreciseDuration(start, end)).toBe('2h 3m') + }) + + it('formats days and hours', () => { + const start = '2026-05-28T10:00:00Z' + const end = '2026-05-30T14:00:00Z' + expect(formatPreciseDuration(start, end)).toBe('2d 4h') + }) + + it('returns 0s for zero duration', () => { + const ts = '2026-05-28T10:00:00Z' + expect(formatPreciseDuration(ts, ts)).toBe('0s') + }) + + it('returns 0s when end is before start', () => { + const start = '2026-05-28T12:00:00Z' + const end = '2026-05-28T10:00:00Z' + expect(formatPreciseDuration(start, end)).toBe('0s') + }) + + it('computes duration to now when no end time', () => { + const recent = new Date(Date.now() - 90 * 1000).toISOString() + const result = formatPreciseDuration(recent) + expect(result).toMatch(/^1m \d+s$/) + }) +}) diff --git a/components/ambient-ui/src/lib/format-timestamp.ts b/components/ambient-ui/src/lib/format-timestamp.ts index a57de99a0..017e3a3ab 100644 --- a/components/ambient-ui/src/lib/format-timestamp.ts +++ b/components/ambient-ui/src/lib/format-timestamp.ts @@ -13,3 +13,18 @@ export function formatDuration(startIso: string, endIso?: string | null): string const end = endIso ? new Date(endIso) : new Date() return formatDistance(start, end) } + +export function formatPreciseDuration(startIso: string, endIso?: string | null): string { + const start = new Date(startIso) + const end = endIso ? new Date(endIso) : new Date() + const diffMs = Math.max(0, end.getTime() - start.getTime()) + const seconds = Math.floor(diffMs / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + + if (days > 0) return `${days}d ${hours % 24}h` + if (hours > 0) return `${hours}h ${minutes % 60}m` + if (minutes > 0) return `${minutes}m ${seconds % 60}s` + return `${seconds}s` +} diff --git a/components/ambient-ui/src/queries/query-keys.ts b/components/ambient-ui/src/queries/query-keys.ts index abaa33af1..8d817dfbc 100644 --- a/components/ambient-ui/src/queries/query-keys.ts +++ b/components/ambient-ui/src/queries/query-keys.ts @@ -19,6 +19,11 @@ export const queryKeys = { detail: (projectId: string) => [...queryKeys.projects.details(), projectId] as const, }, + agents: { + all: ['agents'] as const, + names: (projectId: string) => + [...queryKeys.agents.all, 'names', projectId] as const, + }, messages: { all: ['messages'] as const, lists: () => [...queryKeys.messages.all, 'list'] as const, diff --git a/components/ambient-ui/src/queries/use-agents.ts b/components/ambient-ui/src/queries/use-agents.ts new file mode 100644 index 000000000..1cb4b543e --- /dev/null +++ b/components/ambient-ui/src/queries/use-agents.ts @@ -0,0 +1,28 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { queryKeys } from './query-keys' + +type AgentNameEntry = { + id: string + name: string + displayName: string | null +} + +export function useAgentNames(projectId: string) { + return useQuery({ + queryKey: queryKeys.agents.names(projectId), + queryFn: async (): Promise> => { + const res = await fetch(`/api/ambient/v1/projects/${encodeURIComponent(projectId)}/agents?size=100`) + if (!res.ok) return new Map() + const data: { items?: AgentNameEntry[] } = await res.json() + const map = new Map() + for (const agent of data.items ?? []) { + map.set(agent.id, agent.displayName || agent.name) + } + return map + }, + enabled: !!projectId, + staleTime: 60_000, + }) +}