From 50ddfb8d1a1105198bc6a98eaf1009f36de5fc4f Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 3 Jun 2026 14:23:45 -0400 Subject: [PATCH 01/40] feat(ambient-ui): restructure sidebar into Operate/Build nav groups Replace flat "Project" group with semantic Operate (Sessions) and Build (Agents) groups per the SDLC ops dashboard spec. Extract NavGroup component to reduce duplication. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ambient-ui/src/components/app-sidebar.tsx | 97 ++++++++++++------- 1 file changed, 60 insertions(+), 37 deletions(-) diff --git a/components/ambient-ui/src/components/app-sidebar.tsx b/components/ambient-ui/src/components/app-sidebar.tsx index 30ecb367f..35ff5a1bf 100644 --- a/components/ambient-ui/src/components/app-sidebar.tsx +++ b/components/ambient-ui/src/components/app-sidebar.tsx @@ -28,10 +28,66 @@ type AppSidebarProps = { projectId: string | null } -const projectNavItems = [ +type NavItem = { readonly label: string; readonly icon: typeof Monitor; readonly href: string } + +const operateNavItems: readonly NavItem[] = [ { label: 'Sessions', icon: Monitor, href: 'sessions' }, +] + +const buildNavItems: readonly NavItem[] = [ { label: 'Agents', icon: Bot, href: 'agents' }, -] as const +] + +function NavGroup({ + label, + items, + projectId, + pathname, +}: { + label: string + items: readonly NavItem[] + projectId: string | null + pathname: string +}) { + const isDisabled = !projectId + + return ( + + {label} + + + {items.map((item) => { + const href = projectId ? `/${projectId}/${item.href}` : '#' + const isActive = pathname === href || pathname.startsWith(href + '/') + + return ( + + + {isDisabled ? ( + <> + + {item.label} + + ) : ( + + + {item.label} + + )} + + + ) + })} + + + + ) +} export function AppSidebar({ projectId }: AppSidebarProps) { const pathname = usePathname() @@ -48,41 +104,8 @@ export function AppSidebar({ projectId }: AppSidebarProps) { - - Project - - - {projectNavItems.map((item) => { - const href = projectId ? `/${projectId}/${item.href}` : '#' - const isActive = pathname === href || pathname.startsWith(href + '/') - const isDisabled = !projectId - - return ( - - - {isDisabled ? ( - <> - - {item.label} - - ) : ( - - - {item.label} - - )} - - - ) - })} - - - + + From e15a4da7b56f4cb4dfe9747fc4f3eda6da1e6287 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 3 Jun 2026 14:33:24 -0400 Subject: [PATCH 02/40] feat(ambient-ui): add Dashboard as default project landing page Attention banner surfaces failed sessions, needs-review, and needs-input. Active work section groups running sessions by Jira/PR annotations. Recent activity feed shows last 10 completed sessions. Dashboard badge count in sidebar shows items needing attention. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../_components/active-work-section.tsx | 126 ++++++++++++++++ .../_components/attention-banner.tsx | 67 +++++++++ .../_components/dashboard-helpers.ts | 136 ++++++++++++++++++ .../_components/recent-activity.tsx | 78 ++++++++++ .../src/app/(dashboard)/[projectId]/page.tsx | 74 ++++++++++ .../ambient-ui/src/components/app-sidebar.tsx | 30 +++- 6 files changed, 508 insertions(+), 3 deletions(-) create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/_components/active-work-section.tsx create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/_components/attention-banner.tsx create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/_components/dashboard-helpers.ts create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/_components/recent-activity.tsx create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/page.tsx diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/_components/active-work-section.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/_components/active-work-section.tsx new file mode 100644 index 000000000..459464c09 --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/_components/active-work-section.tsx @@ -0,0 +1,126 @@ +import Link from 'next/link' +import { Ticket, GitPullRequest, Monitor } from 'lucide-react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { PhaseBadge } from '../sessions/_components/phase-badge' +import type { DomainSession } from '@/domain/types' +import type { WorkItemGroup } from './dashboard-helpers' + +type ActiveWorkSectionProps = { + grouped: WorkItemGroup[] + ungrouped: DomainSession[] + projectId: string +} + +const REF_TYPE_CONFIG = { + jira: { icon: Ticket, label: 'Jira' }, + 'github-pr': { icon: GitPullRequest, label: 'PR' }, +} as const + +function WorkItemCard({ + group, + projectId, +}: { + group: WorkItemGroup + projectId: string +}) { + const config = REF_TYPE_CONFIG[group.ref.type] + const Icon = config.icon + + return ( + + + + + {group.ref.key} + + {config.label} + + + + +
    + {group.sessions.map(session => ( +
  • + + + + {session.name} + + {session.agentName && ( + + {session.agentName} + + )} + +
  • + ))} +
+
+
+ ) +} + +function SessionCard({ + session, + projectId, +}: { + session: DomainSession + projectId: string +}) { + return ( + + + + + {session.name} + + + {session.agentName && ( + + {session.agentName} + + )} + + + ) +} + +export function ActiveWorkSection({ grouped, ungrouped, projectId }: ActiveWorkSectionProps) { + const hasWork = grouped.length > 0 || ungrouped.length > 0 + + if (!hasWork) { + return ( +
+

Active work

+

+ No sessions are currently running. +

+
+ ) + } + + return ( +
+

Active work

+
+ {grouped.map(group => ( + + ))} + {ungrouped.map(session => ( + + ))} +
+
+ ) +} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/_components/attention-banner.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/_components/attention-banner.tsx new file mode 100644 index 000000000..bbaaabc1d --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/_components/attention-banner.tsx @@ -0,0 +1,67 @@ +import Link from 'next/link' +import { AlertTriangle, MessageCircle, HelpCircle } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import type { AttentionItem, AttentionReason } from './dashboard-helpers' + +type AttentionBannerProps = { + items: AttentionItem[] + projectId: string +} + +const REASON_CONFIG: Record< + AttentionReason, + { label: string; icon: typeof AlertTriangle; variant: 'destructive' | 'default' | 'secondary' } +> = { + failed: { + label: 'Failed', + icon: AlertTriangle, + variant: 'destructive', + }, + 'needs-review': { + label: 'Needs review', + icon: MessageCircle, + variant: 'default', + }, + 'needs-input': { + label: 'Needs input', + icon: HelpCircle, + variant: 'secondary', + }, +} + +export function AttentionBanner({ items, projectId }: AttentionBannerProps) { + if (items.length === 0) { + return null + } + + return ( +
+

+ Needs attention ({items.length}) +

+
    + {items.map(item => { + const config = REASON_CONFIG[item.reason] + const Icon = config.icon + + return ( +
  • + + + + {item.session.name} + + + {config.label} + + +
  • + ) + })} +
+
+ ) +} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/_components/dashboard-helpers.ts b/components/ambient-ui/src/app/(dashboard)/[projectId]/_components/dashboard-helpers.ts new file mode 100644 index 000000000..ca583a00a --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/_components/dashboard-helpers.ts @@ -0,0 +1,136 @@ +import type { DomainSession, SessionPhase } from '@/domain/types' + +// Annotation keys +const REVIEW_STATUS_KEY = 'ambient-code.io/review/status' +const NEEDS_INPUT_KEY = 'ambient-code.io/agent/needs-input' +const JIRA_ISSUE_KEY = 'ambient-code.io/jira/issue' +const GITHUB_PR_KEY = 'ambient-code.io/github/pr' +const COST_ANNOTATION_KEY = 'ambient-code.io/cost/estimate' + +const ACTIVE_PHASES: ReadonlySet = new Set([ + 'Running', + 'Creating', + 'Pending', + 'Stopping', +]) + +const COMPLETED_PHASES: ReadonlySet = new Set([ + 'Completed', + 'Failed', + 'Stopped', +]) + +export type AttentionReason = 'failed' | 'needs-review' | 'needs-input' + +export type AttentionItem = { + session: DomainSession + reason: AttentionReason +} + +export type WorkItemRef = { + type: 'jira' | 'github-pr' + key: string +} + +export type WorkItemGroup = { + ref: WorkItemRef + sessions: DomainSession[] +} + +export type RecentActivityItem = { + session: DomainSession + ref: WorkItemRef | null + cost: string | null +} + +/** Sessions that need operator attention */ +export function getAttentionItems(sessions: DomainSession[]): AttentionItem[] { + const items: AttentionItem[] = [] + + for (const session of sessions) { + if (session.phase === 'Failed') { + items.push({ session, reason: 'failed' }) + continue + } + + const reviewStatus = session.annotations[REVIEW_STATUS_KEY] + if (reviewStatus === 'needs-review') { + items.push({ session, reason: 'needs-review' }) + continue + } + + const needsInput = session.annotations[NEEDS_INPUT_KEY] + if (needsInput) { + items.push({ session, reason: 'needs-input' }) + } + } + + return items +} + +/** Extract the primary work item reference from a session's annotations */ +function getWorkItemRef(session: DomainSession): WorkItemRef | null { + const jiraKey = session.annotations[JIRA_ISSUE_KEY] + if (jiraKey) { + return { type: 'jira', key: jiraKey } + } + + const prRef = session.annotations[GITHUB_PR_KEY] + if (prRef) { + return { type: 'github-pr', key: prRef } + } + + return null +} + +/** Active sessions grouped by work item reference */ +export function getActiveWorkItems(sessions: DomainSession[]): { + grouped: WorkItemGroup[] + ungrouped: DomainSession[] +} { + const activeSessions = sessions.filter(s => ACTIVE_PHASES.has(s.phase)) + + const groupMap = new Map() + const ungrouped: DomainSession[] = [] + + for (const session of activeSessions) { + const ref = getWorkItemRef(session) + if (!ref) { + ungrouped.push(session) + continue + } + + const groupKey = `${ref.type}:${ref.key}` + const existing = groupMap.get(groupKey) + if (existing) { + existing.sessions.push(session) + } else { + groupMap.set(groupKey, { ref, sessions: [session] }) + } + } + + return { + grouped: Array.from(groupMap.values()), + ungrouped, + } +} + +const RECENT_ACTIVITY_LIMIT = 10 + +/** Recently completed sessions for the activity feed */ +export function getRecentActivity(sessions: DomainSession[]): RecentActivityItem[] { + const completed = sessions + .filter(s => COMPLETED_PHASES.has(s.phase)) + .sort((a, b) => { + const aTime = a.completionTime ?? a.updatedAt + const bTime = b.completionTime ?? b.updatedAt + return new Date(bTime).getTime() - new Date(aTime).getTime() + }) + .slice(0, RECENT_ACTIVITY_LIMIT) + + return completed.map(session => ({ + session, + ref: getWorkItemRef(session), + cost: session.annotations[COST_ANNOTATION_KEY] ?? null, + })) +} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/_components/recent-activity.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/_components/recent-activity.tsx new file mode 100644 index 000000000..b6f5e4628 --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/_components/recent-activity.tsx @@ -0,0 +1,78 @@ +import Link from 'next/link' +import { Ticket, GitPullRequest } from 'lucide-react' +import { PhaseBadge } from '../sessions/_components/phase-badge' +import { formatRelativeTime, formatPreciseDuration } from '@/lib/format-timestamp' +import type { RecentActivityItem } from './dashboard-helpers' + +type RecentActivityProps = { + items: RecentActivityItem[] + projectId: string +} + +const REF_ICONS = { + jira: Ticket, + 'github-pr': GitPullRequest, +} as const + +export function RecentActivity({ items, projectId }: RecentActivityProps) { + if (items.length === 0) { + return ( +
+

Recent activity

+

+ No completed sessions yet. +

+
+ ) + } + + return ( +
+

Recent activity

+
+
    + {items.map(item => { + const { session, ref, cost } = item + const completionTime = session.completionTime ?? session.updatedAt + const duration = session.startTime + ? formatPreciseDuration(session.startTime, session.completionTime) + : null + + return ( +
  • + + + {ref ? ( + + {(() => { + const Icon = REF_ICONS[ref.type] + return + })()} + {ref.key} + + ) : null} + + + {session.name} + + +
    + {duration && ( + {duration} + )} + {cost && ( + {cost} + )} + {formatRelativeTime(completionTime)} +
    +
  • + ) + })} +
+
+
+ ) +} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/page.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/page.tsx new file mode 100644 index 000000000..4a7c29af0 --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/page.tsx @@ -0,0 +1,74 @@ +'use client' + +import { useParams } from 'next/navigation' +import { LayoutDashboard } from 'lucide-react' +import { Skeleton } from '@/components/ui/skeleton' +import { EmptyState } from '@/components/empty-state' +import { useSessions } from '@/queries/use-sessions' +import { AttentionBanner } from './_components/attention-banner' +import { ActiveWorkSection } from './_components/active-work-section' +import { RecentActivity } from './_components/recent-activity' +import { + getAttentionItems, + getActiveWorkItems, + getRecentActivity, +} from './_components/dashboard-helpers' + +export default function DashboardPage() { + const { projectId } = useParams<{ projectId: string }>() + const { data, isLoading, error } = useSessions(projectId) + + if (error) { + return ( +
+

Dashboard

+

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

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

Dashboard

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

Dashboard

+ +
+ ) + } + + const attentionItems = getAttentionItems(sessions) + const { grouped, ungrouped } = getActiveWorkItems(sessions) + const recentItems = getRecentActivity(sessions) + + return ( +
+

Dashboard

+ + + +
+ ) +} diff --git a/components/ambient-ui/src/components/app-sidebar.tsx b/components/ambient-ui/src/components/app-sidebar.tsx index 35ff5a1bf..7b1da267f 100644 --- a/components/ambient-ui/src/components/app-sidebar.tsx +++ b/components/ambient-ui/src/components/app-sidebar.tsx @@ -4,11 +4,14 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' import { useTheme } from 'next-themes' import { + LayoutDashboard, Monitor, Bot, Moon, Sun, } from 'lucide-react' +import { useSessions } from '@/queries/use-sessions' +import { getAttentionItems } from '@/app/(dashboard)/[projectId]/_components/dashboard-helpers' import { ProjectSelector } from '@/components/project-selector' import { Button } from '@/components/ui/button' import { @@ -20,6 +23,7 @@ import { SidebarGroupContent, SidebarGroupLabel, SidebarMenu, + SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, } from '@/components/ui/sidebar' @@ -31,6 +35,7 @@ type AppSidebarProps = { type NavItem = { readonly label: string; readonly icon: typeof Monitor; readonly href: string } const operateNavItems: readonly NavItem[] = [ + { label: 'Dashboard', icon: LayoutDashboard, href: '' }, { label: 'Sessions', icon: Monitor, href: 'sessions' }, ] @@ -43,11 +48,13 @@ function NavGroup({ items, projectId, pathname, + badgeCounts, }: { label: string items: readonly NavItem[] projectId: string | null pathname: string + badgeCounts?: Record }) { const isDisabled = !projectId @@ -57,8 +64,15 @@ function NavGroup({ {items.map((item) => { - const href = projectId ? `/${projectId}/${item.href}` : '#' - const isActive = pathname === href || pathname.startsWith(href + '/') + const href = projectId + ? item.href + ? `/${projectId}/${item.href}` + : `/${projectId}` + : '#' + const isActive = item.href + ? pathname === href || pathname.startsWith(href + '/') + : pathname === href + const badgeCount = badgeCounts?.[item.label] ?? 0 return ( @@ -80,6 +94,9 @@ function NavGroup({ )} + {badgeCount > 0 && ( + {badgeCount} + )} ) })} @@ -92,6 +109,13 @@ function NavGroup({ export function AppSidebar({ projectId }: AppSidebarProps) { const pathname = usePathname() const { theme, setTheme } = useTheme() + const { data: sessionsData } = useSessions(projectId ?? '') + + const operateBadges = (() => { + if (!sessionsData?.items) return undefined + const count = getAttentionItems(sessionsData.items).length + return count > 0 ? { Dashboard: count } : undefined + })() return ( @@ -104,7 +128,7 @@ export function AppSidebar({ projectId }: AppSidebarProps) { - + From 2e993382d5db93a7017e6b74c94ec4b5c1ab8dbe Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 3 Jun 2026 14:33:34 -0400 Subject: [PATCH 03/40] feat(ambient-ui): agent detail page, CRUD, and lifecycle badges Replace Sheet-based agent detail with full page at /{projectId}/agents/{agentId} with three tabs: Overview (editable for Draft, read-only for GitOps), Sessions (filtered history), Config (YAML export). Add agent create/update/delete via port/adapter. Lifecycle badge derived from managed-by annotation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ambient-ui/src/adapters/sdk-agents.ts | 51 +++- .../_components/agent-config-tab.tsx | 125 ++++++++ .../[agentId]/_components/agent-header.tsx | 189 ++++++++++++ .../_components/agent-overview-tab.tsx | 283 ++++++++++++++++++ .../_components/agent-sessions-tab.tsx | 182 +++++++++++ .../[projectId]/agents/[agentId]/page.tsx | 78 +++++ .../agents/_components/agents-table.tsx | 60 +++- .../agents/_components/create-agent-sheet.tsx | 206 +++++++++++++ .../agents/_components/lifecycle-badge.tsx | 30 ++ .../(dashboard)/[projectId]/agents/page.tsx | 48 +-- components/ambient-ui/src/domain/types.ts | 18 ++ components/ambient-ui/src/ports/agents.ts | 11 +- .../ambient-ui/src/queries/use-agents.ts | 42 ++- 13 files changed, 1293 insertions(+), 30 deletions(-) create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/agents/[agentId]/_components/agent-config-tab.tsx create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/agents/[agentId]/_components/agent-header.tsx create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/agents/[agentId]/_components/agent-overview-tab.tsx create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/agents/[agentId]/_components/agent-sessions-tab.tsx create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/agents/[agentId]/page.tsx create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/agents/_components/create-agent-sheet.tsx create mode 100644 components/ambient-ui/src/app/(dashboard)/[projectId]/agents/_components/lifecycle-badge.tsx diff --git a/components/ambient-ui/src/adapters/sdk-agents.ts b/components/ambient-ui/src/adapters/sdk-agents.ts index 8b2c0cdfa..ef9ac52ef 100644 --- a/components/ambient-ui/src/adapters/sdk-agents.ts +++ b/components/ambient-ui/src/adapters/sdk-agents.ts @@ -1,6 +1,13 @@ import { AgentAPI } from 'ambient-sdk' +import type { AgentCreateRequest, AgentPatchRequest } from 'ambient-sdk' import type { AgentsPort } from '@/ports/agents' -import type { DomainAgent, ListParams, PaginatedResult } from '@/domain/types' +import type { + DomainAgent, + DomainAgentCreateRequest, + DomainAgentUpdateRequest, + ListParams, + PaginatedResult, +} from '@/domain/types' import { mapSdkAgentToDomain } from './mappers' import { getConfig } from './sdk-client' @@ -23,6 +30,29 @@ function buildSdkListOptions(params?: ListParams) { } } +function mapDomainCreateToSdk(request: DomainAgentCreateRequest): AgentCreateRequest { + const sdkReq: AgentCreateRequest = { + name: request.name, + project_id: request.projectId, + } + if (request.displayName) sdkReq.display_name = request.displayName + if (request.model) sdkReq.llm_model = request.model + if (request.prompt) sdkReq.prompt = request.prompt + if (request.repoUrl) sdkReq.repo_url = request.repoUrl + if (request.description) sdkReq.description = request.description + return sdkReq +} + +function mapDomainUpdateToSdk(request: DomainAgentUpdateRequest): AgentPatchRequest { + const sdkReq: AgentPatchRequest = {} + if (request.displayName !== undefined) sdkReq.display_name = request.displayName + if (request.model !== undefined) sdkReq.llm_model = request.model + if (request.prompt !== undefined) sdkReq.prompt = request.prompt + if (request.repoUrl !== undefined) sdkReq.repo_url = request.repoUrl + if (request.description !== undefined) sdkReq.description = request.description + return sdkReq +} + export function createAgentsAdapter(): AgentsPort { return { async list(projectId: string, params?: ListParams): Promise> { @@ -45,5 +75,24 @@ export function createAgentsAdapter(): AgentsPort { const agent = await api.get(agentId) return mapSdkAgentToDomain(agent) }, + + async create(projectId: string, request: DomainAgentCreateRequest): Promise { + const api = getProjectScopedAPI(projectId) + const sdkReq = mapDomainCreateToSdk(request) + const agent = await api.create(sdkReq) + return mapSdkAgentToDomain(agent) + }, + + async update(agentId: string, request: DomainAgentUpdateRequest): Promise { + const api = new AgentAPI({ ...getConfig(), project: '_' }) + const sdkReq = mapDomainUpdateToSdk(request) + const agent = await api.update(agentId, sdkReq) + return mapSdkAgentToDomain(agent) + }, + + async delete(agentId: string): Promise { + const api = new AgentAPI({ ...getConfig(), project: '_' }) + await api.delete(agentId) + }, } } diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/agents/[agentId]/_components/agent-config-tab.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/agents/[agentId]/_components/agent-config-tab.tsx new file mode 100644 index 000000000..575afcda8 --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/agents/[agentId]/_components/agent-config-tab.tsx @@ -0,0 +1,125 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { Copy, Download, Check } from 'lucide-react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import type { DomainAgent } from '@/domain/types' + +function agentToYaml(agent: DomainAgent): string { + const lines: string[] = [ + 'apiVersion: ambient-code.io/v1', + 'kind: Agent', + 'metadata:', + ` name: ${agent.name}`, + ] + + const annotationEntries = Object.entries(agent.annotations) + if (annotationEntries.length > 0) { + lines.push(' annotations:') + for (const [key, value] of annotationEntries) { + lines.push(` ${key}: "${value}"`) + } + } + + const labelEntries = Object.entries(agent.labels) + if (labelEntries.length > 0) { + lines.push(' labels:') + for (const [key, value] of labelEntries) { + lines.push(` ${key}: "${value}"`) + } + } + + lines.push('spec:') + + if (agent.displayName) { + lines.push(` displayName: "${agent.displayName}"`) + } + if (agent.description) { + lines.push(` description: "${agent.description}"`) + } + if (agent.model) { + lines.push(` model: ${agent.model}`) + } + if (agent.repoUrl) { + lines.push(` repoUrl: ${agent.repoUrl}`) + } + if (agent.workflowId) { + lines.push(` workflowId: ${agent.workflowId}`) + } + if (agent.prompt) { + lines.push(' prompt: |') + for (const promptLine of agent.prompt.split('\n')) { + lines.push(` ${promptLine}`) + } + } + + return lines.join('\n') + '\n' +} + +export function AgentConfigTab({ agent }: { agent: DomainAgent }) { + const [copied, setCopied] = useState(false) + const yaml = useMemo(() => agentToYaml(agent), [agent]) + + const handleCopy = useCallback(async () => { + await navigator.clipboard.writeText(yaml) + setCopied(true) + globalThis.setTimeout(() => setCopied(false), 2000) + }, [yaml]) + + const handleDownload = useCallback(() => { + const blob = new Blob([yaml], { type: 'text/yaml' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `agent-${agent.name}.yaml` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + }, [yaml, agent.name]) + + return ( +
+ + +
+ Agent Definition (YAML) +
+ + +
+
+
+ +
+            {yaml}
+          
+
+
+
+ ) +} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/agents/[agentId]/_components/agent-header.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/agents/[agentId]/_components/agent-header.tsx new file mode 100644 index 000000000..2980f9d35 --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/agents/[agentId]/_components/agent-header.tsx @@ -0,0 +1,189 @@ +'use client' + +import { useState, useCallback } from 'react' +import { useRouter, useParams } from 'next/navigation' +import { Play, Download, Trash2, MoreVertical } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import type { DomainAgent } from '@/domain/types' +import { LifecycleBadge, getAgentLifecycle } from '../../_components/lifecycle-badge' +import type { AgentLifecycle } from '../../_components/lifecycle-badge' +import { useDeleteAgent } from '@/queries/use-agents' +import { formatRelativeTime } from '@/lib/format-timestamp' + +function agentToYaml(agent: DomainAgent): string { + const lines: string[] = [ + 'apiVersion: ambient-code.io/v1', + 'kind: Agent', + 'metadata:', + ` name: ${agent.name}`, + ] + + const annotationEntries = Object.entries(agent.annotations) + if (annotationEntries.length > 0) { + lines.push(' annotations:') + for (const [key, value] of annotationEntries) { + lines.push(` ${key}: "${value}"`) + } + } + + lines.push('spec:') + if (agent.displayName) lines.push(` displayName: "${agent.displayName}"`) + if (agent.description) lines.push(` description: "${agent.description}"`) + if (agent.model) lines.push(` model: ${agent.model}`) + if (agent.repoUrl) lines.push(` repoUrl: ${agent.repoUrl}`) + if (agent.prompt) { + lines.push(' prompt: |') + for (const promptLine of agent.prompt.split('\n')) { + lines.push(` ${promptLine}`) + } + } + + return lines.join('\n') + '\n' +} + +export function AgentHeader({ + agent, + lifecycle, +}: { + agent: DomainAgent + lifecycle: AgentLifecycle +}) { + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const router = useRouter() + const { projectId } = useParams<{ projectId: string }>() + const deleteAgent = useDeleteAgent() + + const handleConfirmDelete = useCallback(() => { + deleteAgent.mutate(agent.id, { + onSuccess: () => { + setDeleteDialogOpen(false) + router.push(`/${projectId}/agents`) + }, + onError: () => setDeleteDialogOpen(false), + }) + }, [deleteAgent, agent.id, router, projectId]) + + const handleExportYaml = useCallback(() => { + const yaml = agentToYaml(agent) + const blob = new Blob([yaml], { type: 'text/yaml' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `agent-${agent.name}.yaml` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + }, [agent]) + + const handleRunTestSession = useCallback(() => { + router.push(`/${projectId}/sessions?create=true&agentId=${agent.id}`) + }, [router, projectId, agent.id]) + + return ( + <> +
+
+
+
+

+ {agent.displayName ?? agent.name} +

+ +
+ +
+ + + + + + + + + + Export YAML + + + setDeleteDialogOpen(true)} + disabled={deleteAgent.isPending} + className="text-destructive focus:text-destructive" + > + + Delete + + + +
+
+ +
+ {agent.displayName && agent.name !== agent.displayName && ( + + )} + {agent.model && } + {agent.ownerUserId && } + +
+
+
+ + + + + Delete this agent? + + This action cannot be undone. The agent definition will be permanently deleted. + + + + Cancel + + Delete agent + + + + + + ) +} + +function MetaItem({ label, value }: { label: string; value: string }) { + return ( +
+ {label}:{' '} + {value} +
+ ) +} diff --git a/components/ambient-ui/src/app/(dashboard)/[projectId]/agents/[agentId]/_components/agent-overview-tab.tsx b/components/ambient-ui/src/app/(dashboard)/[projectId]/agents/[agentId]/_components/agent-overview-tab.tsx new file mode 100644 index 000000000..8a8bbb649 --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/[projectId]/agents/[agentId]/_components/agent-overview-tab.tsx @@ -0,0 +1,283 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import type { LucideIcon } from 'lucide-react' +import { + Pin, Tag, Ticket, GitPullRequest, GitBranch, FolderGit2, + Layers, ExternalLink, MessageCircle, User, Play, + DollarSign, Siren, Bot, AlertTriangle, Info, +} from 'lucide-react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { getRegisteredAnnotation } from '@/domain/annotations' +import type { DomainAgent } from '@/domain/types' +import type { AgentLifecycle } from '../../_components/lifecycle-badge' +import { useUpdateAgent } from '@/queries/use-agents' + +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 MODEL_OPTIONS = [ + 'claude-sonnet-4-20250514', + 'claude-opus-4-20250514', + 'claude-haiku-35-20241022', +] as const + +function isClickableValue(value: string): boolean { + return /^https?:\/\//.test(value) +} + +export function AgentOverviewTab({ + agent, + lifecycle, +}: { + agent: DomainAgent + lifecycle: AgentLifecycle +}) { + const isGitOps = lifecycle === 'gitops' + const updateAgent = useUpdateAgent() + + const [displayName, setDisplayName] = useState(agent.displayName ?? '') + const [model, setModel] = useState(agent.model ?? '') + const [prompt, setPrompt] = useState(agent.prompt ?? '') + const [repoUrl, setRepoUrl] = useState(agent.repoUrl ?? '') + const [description, setDescription] = useState(agent.description ?? '') + const [saveError, setSaveError] = useState(null) + const [saveSuccess, setSaveSuccess] = useState(false) + + useEffect(() => { + setDisplayName(agent.displayName ?? '') + setModel(agent.model ?? '') + setPrompt(agent.prompt ?? '') + setRepoUrl(agent.repoUrl ?? '') + setDescription(agent.description ?? '') + }, [agent]) + + const handleSave = useCallback(async () => { + setSaveError(null) + setSaveSuccess(false) + try { + await updateAgent.mutateAsync({ + agentId: agent.id, + request: { + displayName: displayName || undefined, + model: model || undefined, + prompt: prompt || undefined, + repoUrl: repoUrl || undefined, + description: description || undefined, + }, + }) + setSaveSuccess(true) + globalThis.setTimeout(() => setSaveSuccess(false), 3000) + } catch (err) { + setSaveError(err instanceof Error ? err.message : 'Failed to save changes.') + } + }, [updateAgent, agent.id, displayName, model, prompt, repoUrl, description]) + + const annotationEntries = Object.entries(agent.annotations) + + return ( +
+ {isGitOps && ( +
+ +
+

GitOps-managed agent

+

+ This agent is managed via GitOps. Edits here will not persist. +

+
+
+ )} + + + + Agent Configuration + + +
+ + setDisplayName(e.target.value)} + placeholder="Human-readable name" + disabled={isGitOps} + /> +
+ +
+ + +
+ +
+ + setRepoUrl(e.target.value)} + placeholder="https://github.com/org/repo" + disabled={isGitOps} + /> +
+ +
+ +