From 20d985051656e1b635da020728e5874908f1fbc5 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 3 Jun 2026 10:53:36 -0400 Subject: [PATCH 1/8] feat(ambient-ui): implement Resources and Details tabs for session detail Enable the remaining two disabled tabs in the session detail view: - Resources tab: shows repositories merged with reconciliation state (clone status badges, active branch, cloned timestamp), MCP servers placeholder, empty state for sessions with no resources - Details tab: shows configuration metadata (model, temperature, max tokens, timeout, workflow), prompt with truncation/expand, environment variables, registered annotations with icons from the annotation registry, raw annotations table, and labels Extends DomainSession with repos, reconciledRepos, conditions, environmentVariables, labels, temperature, maxTokens, timeout, workflowId, prompt, and sdkRestartCount fields parsed from the SDK Session type. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/adapters/__tests__/mappers.test.ts | 208 ++++++++++++++++++ components/ambient-ui/src/adapters/mappers.ts | 91 +++++++- .../__tests__/details-tab.test.tsx | 169 ++++++++++++++ .../_components/__tests__/logs-tab.test.tsx | 11 + .../__tests__/resources-tab.test.tsx | 119 ++++++++++ .../[sessionId]/_components/details-tab.tsx | 192 ++++++++++++++++ .../[sessionId]/_components/resources-tab.tsx | 157 +++++++++++++ .../[projectId]/fleet/[sessionId]/page.tsx | 12 +- .../__tests__/fleet-summary.test.tsx | 11 + components/ambient-ui/src/domain/types.ts | 39 ++++ 10 files changed, 1006 insertions(+), 3 deletions(-) create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/details-tab.test.tsx create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/resources-tab.test.tsx create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/details-tab.tsx create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/resources-tab.tsx diff --git a/components/ambient-ui/src/adapters/__tests__/mappers.test.ts b/components/ambient-ui/src/adapters/__tests__/mappers.test.ts index 7181848384..156b4fdf1f 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 fd783b10f6..a648c67efb 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/__tests__/details-tab.test.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/details-tab.test.tsx new file mode 100644 index 0000000000..85827b0b44 --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/details-tab.test.tsx @@ -0,0 +1,169 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { DetailsTab } from '../details-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('DetailsTab', () => { + 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', () => { + render( + , + ) + expect(screen.getByText('Environment Variables')).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 registered annotations with labels', () => { + render( + , + ) + expect(screen.getByText('Registered Annotations')).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('hides registered annotations when none match registry', () => { + render( + , + ) + expect(screen.queryByText('Registered Annotations')).toBeNull() + }) + + it('renders raw annotations table for all annotations', () => { + render( + , + ) + expect(screen.getByText('Raw Annotations')).toBeTruthy() + expect(screen.getByText('custom-key')).toBeTruthy() + expect(screen.getByText('custom-val')).toBeTruthy() + }) + + it('hides raw annotations when no annotations exist', () => { + render() + expect(screen.queryByText('Raw Annotations')).toBeNull() + }) + + it('renders labels table', () => { + render( + , + ) + expect(screen.getByText('Labels')).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', () => { + const longPrompt = 'x'.repeat(300) + render() + expect(screen.getByText('Prompt')).toBeTruthy() + expect(screen.getByText('Show more')).toBeTruthy() + }) + + it('expands truncated prompt on click', () => { + const longPrompt = 'A'.repeat(100) + 'B'.repeat(200) + render() + fireEvent.click(screen.getByText('Show more')) + 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') + }) +}) 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]/fleet/[sessionId]/_components/__tests__/logs-tab.test.tsx index e067bd256f..f9409ad5dc 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]/fleet/[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]/fleet/[sessionId]/_components/__tests__/resources-tab.test.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/resources-tab.test.tsx new file mode 100644 index 0000000000..578d2403f5 --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/resources-tab.test.tsx @@ -0,0 +1,119 @@ +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() + }) + + it('renders repo table with merged data', () => { + render( + , + ) + expect(screen.getByText('platform')).toBeTruthy() + expect(screen.getByText('https://github.com/org/platform.git')).toBeTruthy() + 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 MCP servers section', () => { + render( + , + ) + expect(screen.getByText('MCP Servers')).toBeTruthy() + expect(screen.getByText(/not yet available/)).toBeTruthy() + }) + + 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/details-tab.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/details-tab.tsx new file mode 100644 index 0000000000..4dd0c3714f --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/details-tab.tsx @@ -0,0 +1,192 @@ +'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 { getRegisteredAnnotations } from '@/domain/annotations' +import { cn } from '@/lib/utils' +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' + +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 DetailsTab({ 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 registered = getRegisteredAnnotations(session.annotations) + + 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 + + +
+ + + + + + +
+
+
+ + {displayPrompt != null && ( + + + Prompt + + +
+              {displayPrompt}
+            
+ {promptNeedsTruncation && ( + + )} +
+
+ )} + + {envEntries.length > 0 && ( + + )} + + {registered.length > 0 && ( + + + Registered Annotations + + +
+ {registered.map(({ annotation, value }) => { + const Icon = annotation.icon ? ICON_MAP[annotation.icon] : null + const clickable = isClickableValue(value) + return ( +
+ {Icon && } + + {annotation.label} + + {clickable ? ( + + {value} + + ) : ( + {value} + )} +
+ ) + })} +
+
+
+ )} + + {annotationEntries.length > 0 && ( + + )} + + {labelEntries.length > 0 && ( + + )} +
+ ) +} + +function MetaRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function KeyValueCard({ title, entries }: { title: string; entries: [string, string][] }) { + return ( + + + {title} + + + + + + Key + Value + + + + {entries.map(([key, value]) => ( + + {key} + {value} + + ))} + +
+
+
+ ) +} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/resources-tab.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/resources-tab.tsx new file mode 100644 index 0000000000..36254c94c9 --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/resources-tab.tsx @@ -0,0 +1,157 @@ +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, Server } from 'lucide-react' + +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 + + + + + + + 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) : '—'} + + + ))} + +
+
+
+ + + + + + MCP Servers + + + +

+ MCP server configuration is not yet available through the API. +

+
+
+
+ ) +} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/page.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/page.tsx index 1d85f3ee97..e7d39132f2 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/page.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/page.tsx @@ -9,6 +9,8 @@ import { SessionHeader } from './_components/session-header' import { PhaseTab } from './_components/phase-tab' import { LogsTab } from './_components/logs-tab' import { ChatTab } from './_components/chat-tab' +import { ResourcesTab } from './_components/resources-tab' +import { DetailsTab } from './_components/details-tab' export default function SessionDetailPage() { const { sessionId } = useParams<{ projectId: string; sessionId: string }>() @@ -49,8 +51,8 @@ export default function SessionDetailPage() { Phase Logs - Resources - Details + Resources + Details Chat @@ -59,6 +61,12 @@ export default function SessionDetailPage() { + + + + + + 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 index 9eb47026ee..7104b04827 100644 --- 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 @@ -12,11 +12,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/domain/types.ts b/components/ambient-ui/src/domain/types.ts index bdd4134821..a2fdea308c 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 = { From 15510e660b3b3c5316895683e1f7f604b61f2be5 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 3 Jun 2026 11:30:22 -0400 Subject: [PATCH 2/8] fix(ambient-ui): address UX critique for session detail tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename tabs: Phase→Overview, Details→Config, add icons to all tabs - Default to Logs tab instead of Overview - Eliminate data overlap between Overview and Config tabs - Unify registered/raw annotations into single list - Mask secret-looking env var values with reveal toggle - Remove dead MCP Servers placeholder - Make repo URLs clickable links with tooltips - Fix hardcoded colors to use theme tokens - Add phase-colored timeline dots - Extract shared MetaRow component - Add section counts, responsive grid, consistent missing values - Show prompt char count on expand toggle Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/details-tab.test.tsx | 91 +++++-- .../__tests__/resources-tab.test.tsx | 19 +- .../[sessionId]/_components/details-tab.tsx | 229 +++++++++++------- .../[sessionId]/_components/meta-row.tsx | 16 ++ .../{phase-tab.tsx => overview-tab.tsx} | 80 +++--- .../[sessionId]/_components/resources-tab.tsx | 37 ++- .../[projectId]/fleet/[sessionId]/page.tsx | 39 ++- .../ambient-ui/src/components/empty-state.tsx | 2 +- 8 files changed, 313 insertions(+), 200 deletions(-) create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/meta-row.tsx rename components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/{phase-tab.tsx => overview-tab.tsx} (61%) diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/details-tab.test.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/details-tab.test.tsx index 85827b0b44..3d0a7e9aa2 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/details-tab.test.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/details-tab.test.tsx @@ -52,13 +52,13 @@ describe('DetailsTab', () => { expect(dashes.length).toBeGreaterThanOrEqual(4) }) - it('renders environment variables table', () => { + it('renders environment variables table with count', () => { render( , ) - expect(screen.getByText('Environment Variables')).toBeTruthy() + expect(screen.getByText('Environment Variables (2)')).toBeTruthy() expect(screen.getByText('NODE_ENV')).toBeTruthy() expect(screen.getByText('production')).toBeTruthy() expect(screen.getByText('DEBUG')).toBeTruthy() @@ -66,10 +66,10 @@ describe('DetailsTab', () => { it('hides environment variables section when empty', () => { render() - expect(screen.queryByText('Environment Variables')).toBeNull() + expect(screen.queryByText(/Environment Variables/)).toBeNull() }) - it('renders registered annotations with labels', () => { + it('renders annotations with friendly labels for registered keys', () => { render( { })} />, ) - expect(screen.getByText('Registered Annotations')).toBeTruthy() + expect(screen.getByText('Annotations (2)')).toBeTruthy() expect(screen.getByText('Jira Issue')).toBeTruthy() expect(screen.getByText('GitHub PR')).toBeTruthy() const hyperfleetMatches = screen.getAllByText('HYPERFLEET-234') @@ -89,60 +89,51 @@ describe('DetailsTab', () => { expect(prMatches.length).toBeGreaterThanOrEqual(1) }) - it('hides registered annotations when none match registry', () => { - render( - , - ) - expect(screen.queryByText('Registered Annotations')).toBeNull() - }) - - it('renders raw annotations table for all annotations', () => { + it('renders raw annotation keys when not registered', () => { render( , ) - expect(screen.getByText('Raw Annotations')).toBeTruthy() + expect(screen.getByText('Annotations (1)')).toBeTruthy() expect(screen.getByText('custom-key')).toBeTruthy() expect(screen.getByText('custom-val')).toBeTruthy() }) - it('hides raw annotations when no annotations exist', () => { + it('hides annotations section when no annotations exist', () => { render() - expect(screen.queryByText('Raw Annotations')).toBeNull() + expect(screen.queryByText(/Annotations/)).toBeNull() }) - it('renders labels table', () => { + it('renders labels table with count', () => { render( , ) - expect(screen.getByText('Labels')).toBeTruthy() + 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() + expect(screen.queryByText(/Labels/)).toBeNull() }) - it('renders prompt with truncation', () => { + it('renders prompt with truncation and char count', () => { const longPrompt = 'x'.repeat(300) render() expect(screen.getByText('Prompt')).toBeTruthy() - expect(screen.getByText('Show more')).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')) + fireEvent.click(screen.getByText('Show more (300 chars)')) expect(screen.getByText('Show less')).toBeTruthy() expect(screen.getByText(longPrompt)).toBeTruthy() }) @@ -150,7 +141,7 @@ describe('DetailsTab', () => { it('renders short prompt without truncation', () => { render() expect(screen.getByText('Fix the auth bug')).toBeTruthy() - expect(screen.queryByText('Show more')).toBeNull() + expect(screen.queryByText(/Show more/)).toBeNull() }) it('renders clickable URL annotation values as links', () => { @@ -166,4 +157,52 @@ describe('DetailsTab', () => { 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('API_KEY')).toBeTruthy() + expect(screen.getByText('••••••••')).toBeTruthy() + expect(screen.queryByText('super-secret-123')).toBeNull() + }) + + it('reveals secret value on toggle click', () => { + render( + , + ) + expect(screen.getByText('••••••••')).toBeTruthy() + fireEvent.click(screen.getByLabelText('Reveal secret value')) + expect(screen.getByText('my-token-value')).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__/resources-tab.test.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/resources-tab.test.tsx index 578d2403f5..625e49b5bc 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/resources-tab.test.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/resources-tab.test.tsx @@ -52,6 +52,7 @@ 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', () => { @@ -61,7 +62,10 @@ describe('ResourcesTab', () => { />, ) expect(screen.getByText('platform')).toBeTruthy() - expect(screen.getByText('https://github.com/org/platform.git')).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() }) @@ -103,12 +107,19 @@ describe('ResourcesTab', () => { expect(statusCell.textContent).toBe('—') }) - it('shows MCP servers section', () => { + it('shows repository count in card title', () => { render( , ) - expect(screen.getByText('MCP Servers')).toBeTruthy() - expect(screen.getByText(/not yet available/)).toBeTruthy() + 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', () => { diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/details-tab.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/details-tab.tsx index 4dd0c3714f..cb1fd73128 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/details-tab.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/details-tab.tsx @@ -11,7 +11,7 @@ import { TableRow, } from '@/components/ui/table' import type { DomainSession } from '@/domain/types' -import { getRegisteredAnnotations } from '@/domain/annotations' +import { getRegisteredAnnotation } from '@/domain/annotations' import { cn } from '@/lib/utils' import type { LucideIcon } from 'lucide-react' import { @@ -30,7 +30,10 @@ import { 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, @@ -42,17 +45,42 @@ const ICON_MAP: Record = { 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 DetailsTab({ 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 registered = getRegisteredAnnotations(session.annotations) const promptNeedsTruncation = session.prompt != null && session.prompt.length > PROMPT_TRUNCATE_LENGTH @@ -70,13 +98,22 @@ export function DetailsTab({ session }: { session: DomainSession }) { Configuration -
- - - - - - +
+ } /> + } /> + } /> + } /> + {session.workflowId} + : + } + /> + {session.sdkRestartCount > 0 && ( + + )}
@@ -87,16 +124,16 @@ export function DetailsTab({ session }: { session: DomainSession }) { Prompt -
-              {displayPrompt}
-            
+
{displayPrompt}
{promptNeedsTruncation && ( )}
@@ -104,89 +141,113 @@ export function DetailsTab({ session }: { session: DomainSession }) { )} {envEntries.length > 0 && ( - - )} - - {registered.length > 0 && ( - Registered Annotations + + Environment Variables ({envEntries.length}) + -
- {registered.map(({ annotation, value }) => { - const Icon = annotation.icon ? ICON_MAP[annotation.icon] : null - const clickable = isClickableValue(value) - return ( -
- {Icon && } - - {annotation.label} - - {clickable ? ( - - {value} - - ) : ( - {value} - )} -
- ) - })} -
+ + + + 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} + + ))} + +
+
+
)} ) } - -function MetaRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { - return ( -
-
{label}
-
{value}
-
- ) -} - -function KeyValueCard({ title, entries }: { title: string; entries: [string, string][] }) { - return ( - - - {title} - - - - - - Key - Value - - - - {entries.map(([key, value]) => ( - - {key} - {value} - - ))} - -
-
-
- ) -} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/meta-row.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/meta-row.tsx new file mode 100644 index 0000000000..e45a22efa4 --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[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]/fleet/[sessionId]/_components/phase-tab.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/overview-tab.tsx similarity index 61% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/phase-tab.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/overview-tab.tsx index 041797ff64..dc6df5ca00 100644 --- 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/overview-tab.tsx @@ -1,15 +1,8 @@ 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' +import { MetaRow, NoValue } from './meta-row' const LIFECYCLE: SessionPhase[] = ['Pending', 'Creating', 'Running'] @@ -20,7 +13,22 @@ const PHASE_ORDER: Record = { Completed: TERMINAL_ORDER, Failed: TERMINAL_ORDER, Stopped: TERMINAL_ORDER, } -export function PhaseTab({ session }: { session: DomainSession }) { +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' + } +} + +export function OverviewTab({ session }: { session: DomainSession }) { const currentOrder = PHASE_ORDER[session.phase] return ( @@ -46,7 +54,7 @@ export function PhaseTab({ session }: { session: DomainSession }) {
@@ -64,7 +72,9 @@ export function PhaseTab({ session }: { session: DomainSession }) {
= TERMINAL_ORDER ? 'bg-foreground border-foreground' : 'bg-background border-muted-foreground/40', + currentOrder >= TERMINAL_ORDER + ? phaseColor(session.phase) + : 'bg-background border-muted-foreground/40', )} /> - Metadata + Timing -
+
- - - - - + } /> + } /> + } /> + } />
- - {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/[sessionId]/_components/resources-tab.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/resources-tab.tsx index 36254c94c9..30cb4db5a3 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/resources-tab.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/resources-tab.tsx @@ -12,7 +12,8 @@ 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, Server } from 'lucide-react' +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', @@ -81,7 +82,7 @@ export function ResourcesTab({ session }: { session: DomainSession }) {
) @@ -93,7 +94,7 @@ export function ResourcesTab({ session }: { session: DomainSession }) { - Repositories + Repositories ({merged.length}) @@ -112,10 +113,18 @@ export function ResourcesTab({ session }: { session: DomainSession }) { {repo.name} - {repo.url} + + {repo.url} + - {repo.branch ?? '—'} + {repo.branch ?? } {repo.status ? ( @@ -126,11 +135,11 @@ export function ResourcesTab({ session }: { session: DomainSession }) { {repo.status} ) : ( - '—' + )} - {repo.clonedAt ? formatAbsoluteTime(repo.clonedAt) : '—'} + {repo.clonedAt ? formatAbsoluteTime(repo.clonedAt) : } ))} @@ -138,20 +147,6 @@ export function ResourcesTab({ session }: { session: DomainSession }) { - - - - - - MCP Servers - - - -

- MCP server configuration is not yet available through the API. -

-
-
) } diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/page.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/page.tsx index e7d39132f2..9cbbdcf567 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/page.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/page.tsx @@ -5,8 +5,15 @@ 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' @@ -15,8 +22,8 @@ import { DetailsTab } from './_components/details-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 'logs' + return new URL(window.location.href).searchParams.get('tab') ?? 'logs' }) const { data: session, isLoading, error } = useSession(sessionId) @@ -49,14 +56,24 @@ export default function SessionDetailPage() { - Phase - Logs - Resources - Details - Chat + + Overview + + + Logs + + + Resources + + + Config + + + Chat + - - + + @@ -64,7 +81,7 @@ export default function SessionDetailPage() { - + diff --git a/components/ambient-ui/src/components/empty-state.tsx b/components/ambient-ui/src/components/empty-state.tsx index 6cca7c5ac2..0f070d52ea 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}
} From 15ffd4779945e6509a0bc5be4eb418c2727c6663 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 3 Jun 2026 11:44:02 -0400 Subject: [PATCH 3/8] fix(ambient-ui): improve navigation based on UX critique MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename "Fleet" → "Sessions" in all user-facing labels - Fix breadcrumbs to show session name on detail pages - Fix sidebar active state on child routes (startsWith) - Remove disabled "Coming soon" sidebar items - Fix session header sticky offset (top-14) - Rename DetailsTab → ConfigTab for naming consistency - Add Ambient wordmark to sidebar header - Demote heading sizes, fix spacing, standardize icons Co-Authored-By: Claude Opus 4.6 (1M context) --- ...tails-tab.test.tsx => config-tab.test.tsx} | 40 ++++---- .../{details-tab.tsx => config-tab.tsx} | 3 +- .../_components/session-header.tsx | 18 ++-- .../[projectId]/fleet/[sessionId]/page.tsx | 18 ++-- .../(dashboard)/[projectId]/fleet/page.tsx | 8 +- .../ambient-ui/src/app/(dashboard)/layout.tsx | 6 +- .../ambient-ui/src/components/app-sidebar.tsx | 94 +++++-------------- .../ambient-ui/src/components/nav-header.tsx | 21 ++++- 8 files changed, 85 insertions(+), 123 deletions(-) rename components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/{details-tab.test.tsx => config-tab.test.tsx} (87%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/{details-tab.tsx => config-tab.tsx} (98%) diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/details-tab.test.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/config-tab.test.tsx similarity index 87% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/details-tab.test.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/config-tab.test.tsx index 3d0a7e9aa2..ffdcfc3d49 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/details-tab.test.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/config-tab.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' -import { DetailsTab } from '../details-tab' +import { ConfigTab } from '../config-tab' import type { DomainSession } from '@/domain/types' function makeSession(overrides: Partial = {}): DomainSession { @@ -32,9 +32,9 @@ function makeSession(overrides: Partial = {}): DomainSession { } } -describe('DetailsTab', () => { +describe('ConfigTab', () => { it('renders configuration metadata', () => { - render() + render() expect(screen.getByText('Configuration')).toBeTruthy() expect(screen.getByText('claude-sonnet-4-20250514')).toBeTruthy() expect(screen.getByText('0.7')).toBeTruthy() @@ -44,7 +44,7 @@ describe('DetailsTab', () => { it('shows dashes for null config values', () => { render( - , ) @@ -54,7 +54,7 @@ describe('DetailsTab', () => { it('renders environment variables table with count', () => { render( - , ) @@ -65,13 +65,13 @@ describe('DetailsTab', () => { }) it('hides environment variables section when empty', () => { - render() + render() expect(screen.queryByText(/Environment Variables/)).toBeNull() }) it('renders annotations with friendly labels for registered keys', () => { render( - { it('renders raw annotation keys when not registered', () => { render( - { }) it('hides annotations section when no annotations exist', () => { - render() + render() expect(screen.queryByText(/Annotations/)).toBeNull() }) it('renders labels table with count', () => { render( - , ) @@ -119,34 +119,34 @@ describe('DetailsTab', () => { }) it('hides labels section when empty', () => { - render() + render() expect(screen.queryByText(/Labels/)).toBeNull() }) it('renders prompt with truncation and char count', () => { const longPrompt = 'x'.repeat(300) - render() + 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() + 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() + render() expect(screen.getByText('Fix the auth bug')).toBeTruthy() expect(screen.queryByText(/Show more/)).toBeNull() }) it('renders clickable URL annotation values as links', () => { render( - { it('masks secret-looking env var values', () => { render( - { it('reveals secret value on toggle click', () => { render( - { }) it('hides Agent Restarts when sdkRestartCount is 0', () => { - render() + render() expect(screen.queryByText('Agent Restarts')).toBeNull() }) it('shows Agent Restarts when sdkRestartCount > 0', () => { - render() + render() expect(screen.getByText('Agent Restarts')).toBeTruthy() expect(screen.getByText('3')).toBeTruthy() }) it('renders Workflow ID with mono styling and tooltip', () => { - render() + render() const wfElement = screen.getByText('wf-abc-123') expect(wfElement).toBeTruthy() expect(wfElement.getAttribute('title')).toBe('Workflow ID') diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/details-tab.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/config-tab.tsx similarity index 98% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/details-tab.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/config-tab.tsx index cb1fd73128..19b65bc9db 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/details-tab.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/config-tab.tsx @@ -12,7 +12,6 @@ import { } from '@/components/ui/table' import type { DomainSession } from '@/domain/types' import { getRegisteredAnnotation } from '@/domain/annotations' -import { cn } from '@/lib/utils' import type { LucideIcon } from 'lucide-react' import { Pin, @@ -75,7 +74,7 @@ function SecretValue({ value }: { value: string }) { ) } -export function DetailsTab({ session }: { session: DomainSession }) { +export function ConfigTab({ session }: { session: DomainSession }) { const [promptExpanded, setPromptExpanded] = useState(false) const envEntries = Object.entries(session.environmentVariables) diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/session-header.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/session-header.tsx index 86f8d2e1e1..343ed06162 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/session-header.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/session-header.tsx @@ -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]/fleet/[sessionId]/page.tsx index 9cbbdcf567..251d3c73a9 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/page.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/page.tsx @@ -17,13 +17,13 @@ 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 { DetailsTab } from './_components/details-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 'logs' - return new URL(window.location.href).searchParams.get('tab') ?? 'logs' + if (typeof window === 'undefined') return 'overview' + return new URL(window.location.href).searchParams.get('tab') ?? 'overview' }) const { data: session, isLoading, error } = useSession(sessionId) @@ -57,19 +57,19 @@ export default function SessionDetailPage() { - Overview + Overview - Logs + Logs - Resources + Resources - Config + Config - Chat + Chat @@ -82,7 +82,7 @@ export default function SessionDetailPage() { - + diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/page.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/page.tsx index 2c46b15f1a..d85f4c31a9 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/page.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/page.tsx @@ -18,7 +18,7 @@ export default function FleetPage() { if (error) { return (
-

Fleet

+

Sessions

Failed to load sessions: {error.message}

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

Fleet

+

Sessions

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

Fleet

+

Sessions

-

Fleet

+

Sessions

= 1 ? segments[0] : null const pageName = segments.length >= 2 ? capitalize(segments[1]) : null - return { projectId, pageName } + const sessionName = segments.length >= 3 && segments[1] === 'fleet' ? segments[2] : null + return { projectId, pageName, sessionName } } function capitalize(s: string): string { @@ -29,7 +30,7 @@ export default function DashboardLayout({ children: React.ReactNode }) { const pathname = usePathname() - const { projectId, pageName } = extractNavContext(pathname) + const { projectId, pageName, sessionName } = extractNavContext(pathname) const { data: project } = useProject(projectId ?? '') return ( @@ -41,6 +42,7 @@ export default function DashboardLayout({ projectId={projectId} projectName={project?.name ?? null} pageName={pageName} + sessionName={sessionName} />
{children}
diff --git a/components/ambient-ui/src/components/app-sidebar.tsx b/components/ambient-ui/src/components/app-sidebar.tsx index d9fe5f81d7..8fdf062647 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: 'fleet' }, ] 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/nav-header.tsx b/components/ambient-ui/src/components/nav-header.tsx index f9ae603fa2..5b0afc4f37 100644 --- a/components/ambient-ui/src/components/nav-header.tsx +++ b/components/ambient-ui/src/components/nav-header.tsx @@ -30,6 +30,15 @@ type NavHeaderProps = { sessionName?: string | null } +/** Maps URL path segments to user-facing breadcrumb labels. */ +const BREADCRUMB_LABEL_MAP: Record = { + Fleet: 'Sessions', +} + +function displayLabel(raw: string): string { + return BREADCRUMB_LABEL_MAP[raw] ?? raw +} + function UserMenu() { const { user, isLoading } = useCurrentUser() @@ -69,17 +78,19 @@ function UserMenu() { } export function NavHeader({ projectId, projectName, pageName, sessionName }: NavHeaderProps) { + const mappedPageName = pageName ? displayLabel(pageName) : null + return (
- + - Ambient + Ambient @@ -95,16 +106,16 @@ export function NavHeader({ projectId, projectName, pageName, sessionName }: Nav )} - {pageName && ( + {mappedPageName && ( <> {sessionName ? ( - {pageName} + {mappedPageName} ) : ( - {pageName} + {mappedPageName} )} From 9ea79095ed001ace09d292b6564ba795f9f36166 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 3 Jun 2026 11:52:11 -0400 Subject: [PATCH 4/8] fix(ambient-ui): resolve agent names, fix duration and last activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fetch agent names from API and resolve ID→name in fleet table - Fix duration: Running sessions compute from start to now, terminal sessions only use completionTime when it's after startTime - Fix last activity: Running sessions show "Active now", terminal sessions show relative time from completionTime - Fix breadcrumb to show session display name instead of raw ID Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fleet/_components/fleet-columns.tsx | 49 +++++++++++++------ .../fleet/_components/fleet-table.tsx | 3 ++ .../(dashboard)/[projectId]/fleet/page.tsx | 4 +- .../ambient-ui/src/app/(dashboard)/layout.tsx | 10 ++-- .../ambient-ui/src/queries/query-keys.ts | 5 ++ .../ambient-ui/src/queries/use-agents.ts | 28 +++++++++++ 6 files changed, 80 insertions(+), 19 deletions(-) create mode 100644 components/ambient-ui/src/queries/use-agents.ts 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 index 1107be31f5..2f2ddda411 100644 --- 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 @@ -6,7 +6,7 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' -import type { DomainSession } from '@/domain/types' +import type { DomainSession, SessionPhase } from '@/domain/types' import { formatRelativeTime, formatDuration } from '@/lib/format-timestamp' import { useChatSidebar } from '@/components/chat-sidebar-context' import { PhaseBadge } from './phase-badge' @@ -14,6 +14,8 @@ 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']) + function ChatColumnButton({ sessionId }: { sessionId: string }) { const { openSidebar, openSessionId } = useChatSidebar() const isActive = openSessionId === sessionId @@ -55,14 +57,17 @@ export const fleetColumns = [ {info.getValue()} ), }), - col.accessor('agentName', { + col.accessor('agentId', { header: 'Agent', cell: info => { - const name = info.getValue() - const agentId = info.row.original.agentId + 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 ( - - {name ?? agentId ?? '—'} + + {resolvedName} ) }, @@ -71,11 +76,15 @@ export const fleetColumns = [ id: 'duration', header: 'Duration', cell: ({ row }) => { - const { startTime, completionTime } = row.original + 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 ( - {formatDuration(startTime, completionTime)} + {formatDuration(startTime, endTime)} ) }, @@ -88,13 +97,25 @@ export const fleetColumns = [ ), }), - col.accessor('updatedAt', { + col.display({ + id: 'lastActivity', header: 'Last Activity', - cell: info => ( - - {formatRelativeTime(info.getValue())} - - ), + cell: ({ row }) => { + const { phase, completionTime, updatedAt } = row.original + if (RUNNING_PHASES.has(phase)) { + return ( + + Active now + + ) + } + const activityTime = completionTime ?? updatedAt + return ( + + {formatRelativeTime(activityTime)} + + ) + }, }), col.display({ id: 'cost', 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 index c54843d2bb..12d222cfa1 100644 --- 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 @@ -22,9 +22,11 @@ import { fleetColumns } from './fleet-columns' export function FleetTable({ sessions, searchFilter, + agentNames, }: { sessions: DomainSession[] searchFilter: string + agentNames?: Map }) { const router = useRouter() const { projectId } = useParams<{ projectId: string }>() @@ -36,6 +38,7 @@ export function FleetTable({ getFilteredRowModel: getFilteredRowModel(), globalFilterFn: 'includesString', state: { globalFilter: searchFilter }, + meta: { agentNames }, }) return ( diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/page.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/page.tsx index d85f4c31a9..2cea2530bf 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/page.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/page.tsx @@ -7,6 +7,7 @@ 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 { FleetTable } from './_components/fleet-table' import { FleetSummary } from './_components/fleet-summary' @@ -14,6 +15,7 @@ export default function FleetPage() { const { projectId } = useParams<{ projectId: string }>() const [search, setSearch] = useState('') const { data, isLoading, error } = useSessions(projectId) + const { data: agentNames } = useAgentNames(projectId) if (error) { return ( @@ -65,7 +67,7 @@ export default function FleetPage() { />
- +
) } diff --git a/components/ambient-ui/src/app/(dashboard)/layout.tsx b/components/ambient-ui/src/app/(dashboard)/layout.tsx index 91e4ffbbf1..589f510c47 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,8 +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 - const sessionName = segments.length >= 3 && segments[1] === 'fleet' ? segments[2] : null - return { projectId, pageName, sessionName } + const sessionId = segments.length >= 3 && segments[1] === 'fleet' ? segments[2] : null + return { projectId, pageName, sessionId } } function capitalize(s: string): string { @@ -30,8 +31,9 @@ export default function DashboardLayout({ children: React.ReactNode }) { const pathname = usePathname() - const { projectId, pageName, sessionName } = extractNavContext(pathname) + const { projectId, pageName, sessionId } = extractNavContext(pathname) const { data: project } = useProject(projectId ?? '') + const { data: session } = useSession(sessionId ?? '', undefined) return ( @@ -42,7 +44,7 @@ export default function DashboardLayout({ projectId={projectId} projectName={project?.name ?? null} pageName={pageName} - sessionName={sessionName} + sessionName={sessionId ? (session?.name ?? sessionId) : null} />
{children}
diff --git a/components/ambient-ui/src/queries/query-keys.ts b/components/ambient-ui/src/queries/query-keys.ts index abaa33af13..8d817dfbc7 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 0000000000..1cb4b543e9 --- /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, + }) +} From 8e0718024ac15e7482ab28949ba7505e7d568640 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 3 Jun 2026 11:54:53 -0400 Subject: [PATCH 5/8] refactor(ambient-ui): rename /fleet route to /sessions Rename the URL path from /{projectId}/fleet to /{projectId}/sessions to match the user-facing "Sessions" label. Updates all route references, sidebar href, breadcrumb logic, and navigation links. Also fixes timeline dot/line alignment in overview tab. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../_components/__tests__/config-tab.test.tsx | 0 .../__tests__/event-type-badge.test.tsx | 0 .../_components/__tests__/logs-tab.test.tsx | 0 .../__tests__/resources-tab.test.tsx | 0 .../[sessionId]/_components/chat-tab.tsx | 0 .../[sessionId]/_components/config-tab.tsx | 0 .../_components/event-announcer.tsx | 0 .../[sessionId]/_components/event-row.tsx | 0 .../_components/event-summary-banner.tsx | 0 .../_components/event-type-badge.tsx | 0 .../_components/live-tail-indicator.tsx | 0 .../[sessionId]/_components/logs-tab.tsx | 0 .../[sessionId]/_components/meta-row.tsx | 0 .../[sessionId]/_components/overview-tab.tsx | 89 +++++++++---------- .../[sessionId]/_components/resources-tab.tsx | 0 .../_components/session-header.tsx | 2 +- .../{fleet => sessions}/[sessionId]/page.tsx | 0 .../__tests__/fleet-summary.test.tsx | 0 .../__tests__/phase-badge.test.tsx | 0 .../_components/fleet-columns.tsx | 0 .../_components/fleet-summary.tsx | 0 .../_components/fleet-table.tsx | 2 +- .../_components/phase-badge.tsx | 0 .../[projectId]/{fleet => sessions}/page.tsx | 0 .../ambient-ui/src/app/(dashboard)/layout.tsx | 2 +- .../ambient-ui/src/app/(dashboard)/page.tsx | 4 +- .../ambient-ui/src/components/app-sidebar.tsx | 2 +- .../src/components/chat-sidebar.tsx | 4 +- .../ambient-ui/src/components/nav-header.tsx | 9 +- .../src/components/project-selector.tsx | 2 +- 30 files changed, 55 insertions(+), 61 deletions(-) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/[sessionId]/_components/__tests__/config-tab.test.tsx (100%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/[sessionId]/_components/__tests__/event-type-badge.test.tsx (100%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/[sessionId]/_components/__tests__/logs-tab.test.tsx (100%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/[sessionId]/_components/__tests__/resources-tab.test.tsx (100%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/[sessionId]/_components/chat-tab.tsx (100%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/[sessionId]/_components/config-tab.tsx (100%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/[sessionId]/_components/event-announcer.tsx (100%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/[sessionId]/_components/event-row.tsx (100%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/[sessionId]/_components/event-summary-banner.tsx (100%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/[sessionId]/_components/event-type-badge.tsx (100%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/[sessionId]/_components/live-tail-indicator.tsx (100%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/[sessionId]/_components/logs-tab.tsx (100%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/[sessionId]/_components/meta-row.tsx (100%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/[sessionId]/_components/overview-tab.tsx (52%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/[sessionId]/_components/resources-tab.tsx (100%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/[sessionId]/_components/session-header.tsx (99%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/[sessionId]/page.tsx (100%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/_components/__tests__/fleet-summary.test.tsx (100%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/_components/__tests__/phase-badge.test.tsx (100%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/_components/fleet-columns.tsx (100%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/_components/fleet-summary.tsx (100%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/_components/fleet-table.tsx (96%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/_components/phase-badge.tsx (100%) rename components/ambient-ui/src/app/(dashboard)/[projectId]/{fleet => sessions}/page.tsx (100%) diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/config-tab.test.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/__tests__/config-tab.test.tsx similarity index 100% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/config-tab.test.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/__tests__/config-tab.test.tsx 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 100% 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 diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/resources-tab.test.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/__tests__/resources-tab.test.tsx similarity index 100% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/__tests__/resources-tab.test.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/__tests__/resources-tab.test.tsx 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]/fleet/[sessionId]/_components/config-tab.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/config-tab.tsx similarity index 100% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/config-tab.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/config-tab.tsx 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]/fleet/[sessionId]/_components/meta-row.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/meta-row.tsx similarity index 100% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/meta-row.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/meta-row.tsx diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/overview-tab.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/overview-tab.tsx similarity index 52% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/overview-tab.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/overview-tab.tsx index dc6df5ca00..1aed9ea287 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/overview-tab.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/overview-tab.tsx @@ -28,6 +28,48 @@ function phaseColor(phase: SessionPhase): string { } } +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] @@ -38,52 +80,7 @@ export function OverviewTab({ session }: { session: DomainSession }) { 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 - ? phaseColor(session.phase) - : 'bg-background border-muted-foreground/40', - )} /> - = TERMINAL_ORDER ? 'font-medium' : 'text-muted-foreground', - )}> - {currentOrder >= TERMINAL_ORDER ? session.phase : 'Terminal'} - -
-
+ diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/resources-tab.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/resources-tab.tsx similarity index 100% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/[sessionId]/_components/resources-tab.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/[sessionId]/_components/resources-tab.tsx 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 99% 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 343ed06162..ff18227dea 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), }) 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 100% 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 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]/sessions/_components/__tests__/fleet-summary.test.tsx similarity index 100% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/__tests__/fleet-summary.test.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/__tests__/fleet-summary.test.tsx 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]/fleet/_components/fleet-columns.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/fleet-columns.tsx similarity index 100% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/fleet-columns.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/fleet-columns.tsx diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/fleet-summary.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/fleet-summary.tsx similarity index 100% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/fleet-summary.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/fleet-summary.tsx diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/fleet-table.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/fleet-table.tsx similarity index 96% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/fleet-table.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/fleet-table.tsx index 12d222cfa1..5d9d5c6533 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/_components/fleet-table.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/fleet-table.tsx @@ -64,7 +64,7 @@ export function FleetTable({ router.push(`/${projectId}/fleet/${row.original.id}`)} + onClick={() => router.push(`/${projectId}/sessions/${row.original.id}`)} > {row.getVisibleCells().map(cell => ( 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 100% 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 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 100% rename from components/ambient-ui/src/app/(dashboard)/[projectId]/fleet/page.tsx rename to components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/page.tsx diff --git a/components/ambient-ui/src/app/(dashboard)/layout.tsx b/components/ambient-ui/src/app/(dashboard)/layout.tsx index 589f510c47..c6609cc845 100644 --- a/components/ambient-ui/src/app/(dashboard)/layout.tsx +++ b/components/ambient-ui/src/app/(dashboard)/layout.tsx @@ -17,7 +17,7 @@ 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 - const sessionId = segments.length >= 3 && segments[1] === 'fleet' ? segments[2] : null + const sessionId = segments.length >= 3 && segments[1] === 'sessions' ? segments[2] : null return { projectId, pageName, sessionId } } diff --git a/components/ambient-ui/src/app/(dashboard)/page.tsx b/components/ambient-ui/src/app/(dashboard)/page.tsx index ec6a00909f..10d2bffdb7 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 8fdf062647..1e8d5e4daa 100644 --- a/components/ambient-ui/src/components/app-sidebar.tsx +++ b/components/ambient-ui/src/components/app-sidebar.tsx @@ -29,7 +29,7 @@ type AppSidebarProps = { } const projectNavItems = [ - { label: 'Sessions', icon: Monitor, href: 'fleet' }, + { label: 'Sessions', icon: Monitor, href: 'sessions' }, ] as const export function AppSidebar({ projectId }: AppSidebarProps) { diff --git a/components/ambient-ui/src/components/chat-sidebar.tsx b/components/ambient-ui/src/components/chat-sidebar.tsx index 533753366c..f9cd204fa1 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/nav-header.tsx b/components/ambient-ui/src/components/nav-header.tsx index 5b0afc4f37..7bf7b7a6bc 100644 --- a/components/ambient-ui/src/components/nav-header.tsx +++ b/components/ambient-ui/src/components/nav-header.tsx @@ -30,10 +30,7 @@ type NavHeaderProps = { sessionName?: string | null } -/** Maps URL path segments to user-facing breadcrumb labels. */ -const BREADCRUMB_LABEL_MAP: Record = { - Fleet: 'Sessions', -} +const BREADCRUMB_LABEL_MAP: Record = {} function displayLabel(raw: string): string { return BREADCRUMB_LABEL_MAP[raw] ?? raw @@ -100,7 +97,7 @@ export function NavHeader({ projectId, projectName, pageName, sessionName }: Nav - {projectName ?? projectId} + {projectName ?? projectId} @@ -112,7 +109,7 @@ export function NavHeader({ projectId, projectName, pageName, sessionName }: Nav {sessionName ? ( - {mappedPageName} + {mappedPageName} ) : ( {mappedPageName} diff --git a/components/ambient-ui/src/components/project-selector.tsx b/components/ambient-ui/src/components/project-selector.tsx index 1924b70222..2d6a0d9fb8 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`) }} > From 471a1e267686aa2c67825a30a1b3bb1811651b8c Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 3 Jun 2026 12:03:56 -0400 Subject: [PATCH 6/8] feat(ambient-ui): enhance sessions table with sorting, sticky chat, and filtering - Add column sorting with smart default (Failed first, then Running) - Pin Chat column to right edge with shadow separator - Make summary bar phase counts clickable for filtering - Summary bar reflects filtered state when search is active - Precise duration format (2h 03m instead of "about 2 hours") - Differentiate "Active now" / "Starting..." / "Stopping..." states - Add visual containment to summary bar - Improve row hover states and keyboard navigation - Add Chat column header icon - Drop uppercase table headers for ops density - Fix hardcoded colors to use semantic tokens Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/fleet-summary.test.tsx | 87 +++++++++++++- .../sessions/_components/fleet-columns.tsx | 96 ++++++++++++++-- .../sessions/_components/fleet-summary.tsx | 47 +++++++- .../sessions/_components/fleet-table.tsx | 108 +++++++++++++++--- .../sessions/_components/phase-badge.tsx | 6 +- .../(dashboard)/[projectId]/sessions/page.tsx | 26 ++++- .../ambient-ui/src/components/ui/table.tsx | 6 +- .../lib/__tests__/format-timestamp.test.ts | 45 +++++++- .../ambient-ui/src/lib/format-timestamp.ts | 15 +++ 9 files changed, 396 insertions(+), 40 deletions(-) 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 index 7104b04827..bdddba2128 100644 --- 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 @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest' -import { render, screen } from '@testing-library/react' +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' @@ -74,4 +74,87 @@ describe('FleetSummary', () => { 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]/sessions/_components/fleet-columns.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/fleet-columns.tsx index 2f2ddda411..8abb553b58 100644 --- 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 @@ -1,4 +1,5 @@ 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 { @@ -7,7 +8,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip' import type { DomainSession, SessionPhase } from '@/domain/types' -import { formatRelativeTime, formatDuration } from '@/lib/format-timestamp' +import { formatRelativeTime, formatPreciseDuration } from '@/lib/format-timestamp' import { useChatSidebar } from '@/components/chat-sidebar-context' import { PhaseBadge } from './phase-badge' @@ -15,10 +16,29 @@ 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']) -function ChatColumnButton({ sessionId }: { sessionId: string }) { +/** 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 ( @@ -31,7 +51,7 @@ function ChatColumnButton({ sessionId }: { sessionId: string }) { e.stopPropagation() openSidebar(sessionId) }} - aria-label="Open chat sidebar" + aria-label={isTerminal ? 'View chat history' : 'Open chat sidebar'} > - {isActive ? 'Chat sidebar is open' : 'Open chat in sidebar'} + {isActive + ? 'Chat sidebar is open' + : isTerminal + ? 'View chat history' + : 'Open chat in sidebar'} ) @@ -50,6 +74,8 @@ export const fleetColumns = [ header: 'Phase', cell: info => , size: 130, + enableSorting: true, + sortingFn: phaseSortingFn, }), col.accessor('name', { header: 'Name', @@ -66,7 +92,7 @@ export const fleetColumns = [ const resolvedName = annotationName ?? (agentId ? agentNames?.get(agentId) : null) ?? null if (!resolvedName) return return ( - + {resolvedName} ) @@ -75,6 +101,17 @@ export const fleetColumns = [ 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 @@ -84,7 +121,7 @@ export const fleetColumns = [ : null return ( - {formatDuration(startTime, endTime)} + {formatPreciseDuration(startTime, endTime)} ) }, @@ -100,15 +137,42 @@ export const fleetColumns = [ 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 (RUNNING_PHASES.has(phase)) { + + if (phase === 'Running') { return ( - + Active now ) } + + if (phase === 'Creating' || phase === 'Pending') { + return ( + + Starting... + + ) + } + + if (phase === 'Stopping') { + return ( + + Stopping... + + ) + } + const activityTime = completionTime ?? updatedAt return ( @@ -120,6 +184,15 @@ export const fleetColumns = [ 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 ( @@ -132,8 +205,11 @@ export const fleetColumns = [ }), col.display({ id: 'chat', - header: '', - cell: ({ row }) => , + 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 index 431ae8bf28..87752b5f98 100644 --- 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 @@ -1,21 +1,62 @@ +import { cn } from '@/lib/utils' import type { DomainSession, SessionPhase } from '@/domain/types' import { PhaseBadge } from './phase-badge' -export function FleetSummary({ sessions }: { sessions: DomainSession[] }) { +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 ( -
- {sessions.length} sessions +
+ + {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 (
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 index 5d9d5c6533..212ac19d82 100644 --- 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 @@ -1,12 +1,16 @@ '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, @@ -16,31 +20,71 @@ import { TableRow, } from '@/components/ui/table' import { TooltipProvider } from '@/components/ui/tooltip' -import type { DomainSession } from '@/domain/types' +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 }, + 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 (
@@ -48,13 +92,35 @@ export function FleetTable({ {table.getHeaderGroups().map(headerGroup => ( - {headerGroup.headers.map(header => ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} + {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 && ( + + )} +
+
+ ) + })}
))}
@@ -63,14 +129,26 @@ export function FleetTable({ 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 => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} + {row.getVisibleCells().map(cell => { + const isChat = cell.column.id === 'chat' + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} )) ) : ( diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/phase-badge.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_components/phase-badge.tsx index fa5dd6e6e9..6335ea2e77 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/_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]/sessions/page.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/page.tsx index 2cea2530bf..49efae8969 100644 --- a/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/page.tsx +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/sessions/page.tsx @@ -1,6 +1,6 @@ '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' @@ -8,15 +8,22 @@ 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 (
@@ -60,14 +67,25 @@ export default function FleetPage() {

Sessions

setSearch(e.target.value)} className="max-w-xs" />
- - + +
) } diff --git a/components/ambient-ui/src/components/ui/table.tsx b/components/ambient-ui/src/components/ui/table.tsx index e2185e5d14..ec58487188 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/lib/__tests__/format-timestamp.test.ts b/components/ambient-ui/src/lib/__tests__/format-timestamp.test.ts index 678da4af14..161561ce2a 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 a57de99a0a..017e3a3ab8 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` +} From 46ac03e3d432dd28c64da3af3107205b696713ca Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 3 Jun 2026 12:33:06 -0400 Subject: [PATCH 7/8] fix(ambient-ui): reduce tooltip delay on chat action button Co-Authored-By: Claude Opus 4.6 (1M context) --- .../[projectId]/sessions/_components/fleet-table.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 212ac19d82..0fa6eddfa6 100644 --- 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 @@ -86,7 +86,7 @@ export function FleetTable({ }, [filteredRowCount, onFilteredCountChange]) return ( - +
From fc8efa9134e6c9d0cac7ddf5243dc1a4257c605f Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 3 Jun 2026 12:34:16 -0400 Subject: [PATCH 8/8] fix(ambient-ui): replace secret-looking test fixture values Co-Authored-By: Claude Opus 4.6 (1M context) --- .../_components/__tests__/config-tab.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 index ffdcfc3d49..f8d959e6bf 100644 --- 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 @@ -162,28 +162,28 @@ describe('ConfigTab', () => { render( , ) expect(screen.getByText('NODE_ENV')).toBeTruthy() expect(screen.getByText('production')).toBeTruthy() - expect(screen.getByText('API_KEY')).toBeTruthy() + expect(screen.getByText('CREDENTIAL_ID')).toBeTruthy() expect(screen.getByText('••••••••')).toBeTruthy() - expect(screen.queryByText('super-secret-123')).toBeNull() + 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('my-token-value')).toBeTruthy() + expect(screen.getByText('revealed-val')).toBeTruthy() expect(screen.queryByText('••••••••')).toBeNull() })