diff --git a/.changeset/agents-library-usage-columns.md b/.changeset/agents-library-usage-columns.md new file mode 100644 index 000000000..ea0cb8008 --- /dev/null +++ b/.changeset/agents-library-usage-columns.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge-ui': patch +--- + +Dense Agents library rows: nest creator under the name, two-line configuration, and per-page sessions/spend/14d sparkline/last-run when metrics are available. diff --git a/packages/trueforge-ui/src/atoms/AgentLibraryUsage.tsx b/packages/trueforge-ui/src/atoms/AgentLibraryUsage.tsx new file mode 100644 index 000000000..d7bf21593 --- /dev/null +++ b/packages/trueforge-ui/src/atoms/AgentLibraryUsage.tsx @@ -0,0 +1,326 @@ +'use client'; + +import { useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'; + +import { formatRelativeShort } from './lib/threadListMeta.js'; +import { + AGENT_LIBRARY_METERS_WINDOW_DAYS, + AGENT_LIBRARY_SPARKLINE_DAYS, + type AgentLibraryDailyPoint, + type AgentLibraryUsageStats, +} from './lib/useAgentLibraryUsageStats.js'; +import { Skeleton } from './primitives/Skeleton.js'; +import { Tooltip } from './primitives/Tooltip.js'; + +/** Listing spend (e.g. $151.85). */ +function formatLibraryCostUsd(costInUsd: number): string { + return `$${costInUsd.toFixed(2)}`; +} + +/** Backend day buckets are UTC midnight; local calendar would name the previous day west of UTC. */ +const sparklineDayFormatter = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + timeZone: 'UTC', +}); + +function formatSparklineDay(iso: string): string { + const ms = Date.parse(iso); + if (Number.isNaN(ms)) return iso; + return sparklineDayFormatter.format(new Date(ms)); +} + +function formatLastRunLabel(iso: string | null): string { + if (iso == null) return 'Never'; + const ms = Date.parse(iso); + if (Number.isNaN(ms)) return '—'; + return `${formatRelativeShort(new Date(ms))} ago`; +} + +export const agentLibraryUsageLabels = { + sessions: `Sessions, ${String(AGENT_LIBRARY_METERS_WINDOW_DAYS)}d`, + spend: `Spend, ${String(AGENT_LIBRARY_METERS_WINDOW_DAYS)}d`, + daily: `Daily, ${String(AGENT_LIBRARY_SPARKLINE_DAYS)}d`, +} as const; + +function nearestSparklineIndex({ + clientX, + svg, + count, + width, +}: { + clientX: number; + svg: SVGSVGElement; + count: number; + width: number; +}): number { + if (count <= 1) return 0; + const rect = svg.getBoundingClientRect(); + const x = ((clientX - rect.left) / Math.max(rect.width, 1)) * width; + const step = width / (count - 1); + return Math.max(0, Math.min(count - 1, Math.round(x / step))); +} + +function AgentLibrarySparkline({ + points, + agentName, +}: { + points: readonly AgentLibraryDailyPoint[] | null | undefined; + agentName: string; +}) { + const [hover, setHover] = useState<{ + index: number; + clientX: number; + clientY: number; + } | null>(null); + + if (points == null) { + return ( + + — + + ); + } + if (points.length === 0 || points.every(point => point.value === 0)) { + return ( + + ); + } + + const width = 80; + const height = 28; + const padY = 2; + // Spend is never negative; anchoring at 0 keeps a cheap day from looking like a crash. + const max = Math.max(0, ...points.map(point => point.value)); + const span = max || 1; + const stepX = points.length === 1 ? 0 : width / (points.length - 1); + const coords = points.map((point, index) => { + const x = points.length === 1 ? width / 2 : index * stepX; + const y = height - padY - (Math.max(0, point.value) / span) * (height - padY * 2); + return { x, y }; + }); + const polyline = coords.map(({ x, y }) => `${String(x)},${String(y)}`).join(' '); + const hoverPoint = hover != null ? points[hover.index] : null; + const hoverCoord = hover != null ? coords[hover.index] : null; + + const onMouseMove = (event: ReactMouseEvent) => { + const index = nearestSparklineIndex({ + clientX: event.clientX, + svg: event.currentTarget, + count: points.length, + width, + }); + setHover({ index, clientX: event.clientX, clientY: event.clientY }); + }; + + return ( + + {formatSparklineDay(hoverPoint.timestamp)} + {formatLibraryCostUsd(hoverPoint.value)} + + ) + } + > + setHover(null)} + > + + {hoverCoord != null ? ( + <> + + + + ) : null} + + + ); +} + +export function AgentLibrarySparklineCell({ + points, + loading, + agentName, +}: { + points: readonly AgentLibraryDailyPoint[] | null | undefined; + loading: boolean; + agentName: string; +}) { + if (loading) return ; + return ; +} + +export function AgentSessionsCell({ + usage, + loading, + agentName, +}: { + usage: AgentLibraryUsageStats | undefined; + loading: boolean; + agentName: string; +}) { + if (loading) return ; + if (usage?.totalSessions == null) { + return ( + + — + + ); + } + return ( + + {usage.totalSessions} + + ); +} + +export function AgentSpendCell({ + usage, + loading, + agentName, +}: { + usage: AgentLibraryUsageStats | undefined; + loading: boolean; + agentName: string; +}) { + if (loading) { + return ( +
+ + +
+ ); + } + if (usage?.totalCostUsd == null) { + return ( + + — + + ); + } + return ( +
+ {formatLibraryCostUsd(usage.totalCostUsd)} + {usage.costPerSessionUsd != null ? ( + {formatLibraryCostUsd(usage.costPerSessionUsd)} / session + ) : null} +
+ ); +} + +export function AgentLastRunCell({ + usage, + loading, + agentName, +}: { + usage: AgentLibraryUsageStats | undefined; + loading: boolean; + agentName: string; +}) { + if (loading) return ; + if (usage?.lastRunAt === undefined) { + return ( + + — + + ); + } + const label = formatLastRunLabel(usage.lastRunAt); + return ( + + {label} + + ); +} + +function UsageField({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +export type AgentLibraryUsageCellsProps = { + usage?: AgentLibraryUsageStats; + loading: boolean; + agentName: string; + showMetrics: boolean; + showLastRun: boolean; +}; + +/** Mobile card usage block. Desktop rows place the same cells in table columns. */ +export function AgentLibraryUsageCells({ + usage, + loading, + agentName, + showMetrics, + showLastRun, +}: AgentLibraryUsageCellsProps) { + if (!showMetrics && !showLastRun) return null; + if (!showMetrics) { + return ( + + + + ); + } + return ( +
+ + + + + + + + + + {showLastRun ? ( + + + + ) : null} +
+ ); +} + +declare module '../theme/SlotsProvider.js' { + interface AtomSlots { + AgentLibraryUsageCells: typeof AgentLibraryUsageCells; + } +} diff --git a/packages/trueforge-ui/src/atoms/AgentsLibrary.tsx b/packages/trueforge-ui/src/atoms/AgentsLibrary.tsx index 4689b67e5..1b742044b 100644 --- a/packages/trueforge-ui/src/atoms/AgentsLibrary.tsx +++ b/packages/trueforge-ui/src/atoms/AgentsLibrary.tsx @@ -11,17 +11,24 @@ import { Icon } from '../icons/Icon.js'; import { isSchedulesChromeEnabled, isSessionsChromeEnabled } from '../server/serverChrome.js'; import { useOptionalAgentSessionsServer, useOptionalScheduleServer } from '../server/ServerContext.js'; import { libraryAgentId, useShellMode } from '../server/ShellModeContext.js'; -import type { AgentLibraryEntry, AgentSpec, Schedule } from '../server/types.js'; +import type { AgentLibraryEntry, AgentSpec, CreatedBySubject, Schedule } from '../server/types.js'; import { useSlot } from '../theme/SlotsProvider.js'; -import { hasCreatedBySubject } from '../utils/createdBySubject.js'; import { readLibraryShareSearch, replaceLibraryShareSearch } from '../utils/libraryShareUrl.js'; import { replaceScheduleShareSearch } from '../utils/scheduleShareUrl.js'; +import { + AgentLastRunCell, + AgentLibrarySparklineCell, + AgentSessionsCell, + AgentSpendCell, + agentLibraryUsageLabels, +} from './AgentLibraryUsage.js'; import { AgentOverflowMenu } from './AgentOverflowMenu.js'; import { CreatedByCell } from './CreatedByCell.js'; import { EmptyScreen, EmptyScreenQueryHighlight } from './EmptyScreen.js'; import { cn } from './lib/cn.js'; import { isMobileNavDrawerOpen } from './lib/isMobileNavDrawerOpen.js'; import { mountName } from './lib/mountName.js'; +import { useAgentLibraryUsageStats, type AgentLibraryUsageStats } from './lib/useAgentLibraryUsageStats.js'; import { useIsMobile } from './lib/useIsMobile.js'; import { useSearchAgentsList } from './lib/useSearchAgentsList.js'; import { PageHeader } from './PageHeader.js'; @@ -63,8 +70,10 @@ export type AgentLibraryRowProps = { canManageAgent?: boolean; canDeleteAgent?: boolean; canManageSchedules: boolean; - showCreatedBy?: boolean; scheduleSummary?: AgentScheduleSummary | null; + usage?: AgentLibraryUsageStats; + showMetricsColumns?: boolean; + showLastRunColumn?: boolean; onOpenSchedules?: () => void; onCreateSchedule?: () => void; onOpen?: () => void; @@ -81,6 +90,22 @@ function displayModelLabel(modelName: string): string { return slash >= 0 ? modelName.slice(slash + 1) : modelName; } +/** Sectioned list tooltip for MCP / skill mounts (one name per line). */ +function mountNamesTooltip({ title, names }: { title: string; names: readonly string[] }) { + return ( + + {title} + + {names.map(name => ( + + {name} + + ))} + + + ); +} + function AgentSchedulesEmptyState({ agentName, onOpen, @@ -118,17 +143,17 @@ function AgentSchedulesEmptyState({ function AgentNameContent({ name, description, + createdBySubject, onOpen, - descriptionMode = 'tooltip', }: { name: string; description: string | null; + createdBySubject?: CreatedBySubject; onOpen?: () => void; - descriptionMode?: 'tooltip' | 'clamp'; }) { const nameNode = onOpen == null ? ( - {name} + {name} ) : ( ); - if (description == null) { - return <>{nameNode}; - } - - if (descriptionMode === 'clamp') { - return ( - <> - {nameNode} - {description} - - ); - } - - return ( - <> - {nameNode} + const titledName = + description == null ? ( + nameNode + ) : ( - {description} + {nameNode} - + ); + + return ( +
+ {titledName} + {createdBySubject != null ? : null} +
); } -function AgentConfigurationChips({ +function AgentConfigurationSummary({ agentName, modelLabel, modelTitle, - skillsCount, - skillsTitle, - mcpCount, - connectorsTitle, + skillNames, + mcpNames, className, }: { agentName: string; modelLabel: string | null; modelTitle: string; - skillsCount: number; - skillsTitle: string; - mcpCount: number; - connectorsTitle: string; + skillNames: readonly string[]; + mcpNames: readonly string[]; className?: string; }) { + const skillsCount = skillNames.length; + const mcpCount = mcpNames.length; const hasConfiguration = modelLabel != null || skillsCount > 0 || mcpCount > 0; if (!hasConfiguration) { return ( @@ -194,35 +211,48 @@ function AgentConfigurationChips({ ); } + return ( -
+
{modelLabel != null ? ( - - - {modelLabel} + + {modelLabel} - ) : null} - {skillsCount > 0 ? ( - - - - {skillsCount} - - - ) : null} - {mcpCount > 0 ? ( - - - - {mcpCount} - - - ) : null} + ) : ( + — + )} + {mcpCount === 0 && skillsCount === 0 ? ( + no tools + ) : ( + + {mcpCount > 0 ? ( + + + {mcpCount} MCP + + + ) : null} + {mcpCount > 0 && skillsCount > 0 ? • : null} + {skillsCount > 0 ? ( + + + {skillsCount} skill{skillsCount === 1 ? '' : 's'} + + + ) : null} + + )}
); } @@ -336,8 +366,10 @@ export function AgentLibraryRow({ canManageAgent = true, canDeleteAgent = true, canManageSchedules, - showCreatedBy = false, scheduleSummary, + usage, + showMetricsColumns = false, + showLastRunColumn = false, onOpenSchedules, onCreateSchedule, onOpen, @@ -347,22 +379,29 @@ export function AgentLibraryRow({ variant = 'row', }: AgentLibraryRowProps) { const PermissionGuard = useSlot('PermissionGuard'); + const SlottedUsageCells = useSlot('AgentLibraryUsageCells'); const spec = agent.agentSpec; const modelName = spec?.model.name; - const skillsCount = spec?.skills?.length ?? 0; - const mcpCount = spec?.mcpServers?.length ?? 0; const skillNames = (spec?.skills ?? []).map(mountName).filter((name: string | null): name is string => name != null); const mcpNames = (spec?.mcpServers ?? []) .map(mountName) .filter((name: string | null): name is string => name != null); const modelLabel = modelName != null ? displayModelLabel(modelName) : null; const modelTitle = modelName ?? ''; - const connectorsTitle = mcpNames.length ? mcpNames.join(', ') : `${mcpCount} connectors`; - const skillsTitle = skillNames.length ? skillNames.join(', ') : `${skillsCount} skills`; const hasNoSchedules = scheduleSummary != null && scheduleSummary.count === 0; const storedDescription = agent.description?.trim() || null; // Create falls back to name when description is missing; don't echo it under the title. const description = storedDescription != null && storedDescription !== agent.name ? storedDescription : null; + const usageLoading = (showMetricsColumns || showLastRunColumn) && usage == null; + const usageCells = ( + + ); const overflowMenu = ( ); - const configurationChips = ( - + ); + + const nameContent = ( + ); @@ -404,27 +450,11 @@ export function AgentLibraryRow({ return (
  • -
    - -
    +
    {nameContent}
    {overflowMenu}
    - - {showCreatedBy ? : null} + {configurationSummary} + {usageCells}
    {/* Fixed width so truncate works; 24rem = 1.5× the prior min-w-64 name column. */} - - - - {configurationChips} - {showCreatedBy ? ( - - - - ) : null} + {nameContent} + {configurationSummary} {scheduleSummary !== undefined ? ( ) : null} + {showMetricsColumns ? ( + <> + + + + + + + + + + + ) : null} -
    +
    + {showLastRunColumn ? : null} {tryButton} {overflowMenu}
    @@ -585,13 +622,21 @@ export function AgentsLibrary({ onSelectAgent, headerStart }: AgentsLibraryProps limit: DEFAULT_TABLE_PAGE_SIZE, }); const hasPageNav = canPrev || canNext; - const showCreatedByColumn = hasCreatedBySubject(agents); const permissionAgentIds = open ? agents.map(libraryAgentId) : []; const { allows } = useResourcePermissions({ resourceType: 'agent', resourceIds: permissionAgentIds, }); + const { + usageByAgentId, + metricsEnabled: showMetricsColumns, + lastRunEnabled: showLastRunColumn, + } = useAgentLibraryUsageStats({ + agentIds: permissionAgentIds, + refreshKey: agentsListEpoch, + }); + useEffect(() => { if (!open || scheduleServer == null || agents.length === 0) { setScheduleByAgent(null); @@ -664,6 +709,7 @@ export function AgentsLibrary({ onSelectAgent, headerStart }: AgentsLibraryProps scheduleByAgent?.get(agent.name) ?? (scheduleByAgent == null ? null : { count: 0, pausedCount: 0, activeNames: [], pausedNames: [] })) : undefined; + const usage = usageByAgentId.get(id); return ( {Array.from({ length: 6 }, (_, i) => ( - + ))}
    ) : error ? ( @@ -810,12 +858,20 @@ export function AgentsLibrary({ onSelectAgent, headerStart }: AgentsLibraryProps - Agent name + Agent Configuration - {showCreatedByColumn ? Created by : null} {showSchedulesColumn ? Schedules : null} + {showMetricsColumns ? ( + <> + {agentLibraryUsageLabels.sessions} + {agentLibraryUsageLabels.spend} + {agentLibraryUsageLabels.daily} + + ) : null} - Actions + + {showLastRunColumn ? 'Last run' : 'Actions'} + diff --git a/packages/trueforge-ui/src/atoms/lib/useAgentLibraryUsageStats.ts b/packages/trueforge-ui/src/atoms/lib/useAgentLibraryUsageStats.ts new file mode 100644 index 000000000..7790e0a49 --- /dev/null +++ b/packages/trueforge-ui/src/atoms/lib/useAgentLibraryUsageStats.ts @@ -0,0 +1,210 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +import { useOptionalAgentMetricsServer, useOptionalAgentSessionsServer } from '../../server/ServerContext.js'; +import type { + AgentMetricMeter, + AgentMetricPoint, + AgentMetricsServer, + AgentSessionsServer, +} from '../../server/types.js'; +import { DEFAULT_SESSION_TIME_WINDOW_MS } from '../../utils/sessionShareUrl.js'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +/** Daily cost series length. Meters use the session time window below. */ +export const AGENT_LIBRARY_SPARKLINE_DAYS = 14; +/** Day count of `DEFAULT_SESSION_TIME_WINDOW_MS`, which the library meters query uses. */ +export const AGENT_LIBRARY_METERS_WINDOW_DAYS = DEFAULT_SESSION_TIME_WINDOW_MS / MS_PER_DAY; +const SPARKLINE_WINDOW_MS = AGENT_LIBRARY_SPARKLINE_DAYS * MS_PER_DAY; +const CHART_NAME = 'sessions_cost_over_time'; + +export type AgentLibraryDailyPoint = { + timestamp: string; + value: number; +}; + +export type AgentLibraryUsageStats = { + totalSessions: number | null; + totalCostUsd: number | null; + costPerSessionUsd: number | null; + dailyPoints: AgentLibraryDailyPoint[] | null; + /** + * Present only after `listSessions` succeeds. `null` means the agent has no + * sessions; omitted when the fetch failed (distinct from "never ran"). + */ + lastRunAt?: string | null; +}; + +function meterValue(meters: readonly AgentMetricMeter[], name: string): number | null { + const match = meters.find(meter => meter.name === name); + return match == null || !Number.isFinite(match.aggregateValue) ? null : match.aggregateValue; +} + +function chartPoints(points: readonly AgentMetricPoint[] | undefined): AgentLibraryDailyPoint[] | null { + if (points == null) return null; + return points.map(point => ({ + timestamp: point.timestamp, + value: Number.isFinite(point.value) ? point.value : 0, + })); +} + +function unavailableUsage(): AgentLibraryUsageStats { + return { + totalSessions: null, + totalCostUsd: null, + costPerSessionUsd: null, + dailyPoints: null, + }; +} + +async function loadUsageForAgent({ + agentId, + metrics, + sessions, + metersStart, + metersEnd, + sparklineStart, + sparklineEnd, +}: { + agentId: string; + metrics: AgentMetricsServer | null; + sessions: AgentSessionsServer | null; + metersStart: string; + metersEnd: string; + sparklineStart: string; + sparklineEnd: string; +}): Promise { + const metersPromise = + metrics == null + ? Promise.resolve({ ok: false as const }) + : metrics + .getMeters({ + agentId, + startTimestamp: metersStart, + endTimestamp: metersEnd, + }) + .then( + meters => ({ ok: true as const, meters }), + () => ({ ok: false as const }), + ); + + const chartPromise = + metrics == null + ? Promise.resolve({ ok: false as const }) + : metrics + .getChartData({ + agentId, + startTimestamp: sparklineStart, + endTimestamp: sparklineEnd, + chartName: CHART_NAME, + }) + .then( + data => ({ ok: true as const, data }), + () => ({ ok: false as const }), + ); + + // `undefined` = sessions server absent or the call failed; `null` = no sessions. + const lastRunPromise: Promise = + sessions == null + ? Promise.resolve(undefined) + : sessions.listSessions({ agentId, limit: 1, order: 'desc' }).then( + page => page.data[0]?.lastActivityAt ?? null, + () => undefined, + ); + + const [metersResult, chartResult, lastRunAt] = await Promise.all([metersPromise, chartPromise, lastRunPromise]); + + const meters = metersResult.ok ? metersResult.meters : []; + const dailyPoints = chartResult.ok ? chartPoints(chartResult.data.graphs[0]?.graphLines[0]?.values) : null; + + return { + totalSessions: metersResult.ok ? meterValue(meters, 'total_sessions') : null, + totalCostUsd: metersResult.ok ? meterValue(meters, 'total_cost_in_usd') : null, + costPerSessionUsd: metersResult.ok ? meterValue(meters, 'cost_per_session_in_usd') : null, + dailyPoints, + ...(lastRunAt === undefined ? {} : { lastRunAt }), + }; +} + +/** + * Loads per-agent spend/session meters, a 14d cost sparkline, and last-run + * for the visible library page. Each agent paints as its own requests finish. + * ponytail: three requests per agent (N+1). Replace with one batched metrics + * call if the library page grows past a few dozen rows. + */ +export function useAgentLibraryUsageStats({ + agentIds, + refreshKey, +}: { + agentIds: readonly string[]; + refreshKey: number; +}): { + usageByAgentId: Map; + metricsEnabled: boolean; + lastRunEnabled: boolean; +} { + const metrics = useOptionalAgentMetricsServer(); + const sessions = useOptionalAgentSessionsServer(); + const metricsEnabled = metrics != null; + const lastRunEnabled = sessions != null; + const [usageByAgentId, setUsageByAgentId] = useState>(() => new Map()); + const agentIdsKey = agentIds.join('\0'); + const loadedIdsKeyRef = useRef(null); + + useEffect(() => { + const ids = agentIdsKey.length === 0 ? [] : agentIdsKey.split('\0'); + if (ids.length === 0 || (metrics == null && sessions == null)) { + loadedIdsKeyRef.current = agentIdsKey; + setUsageByAgentId(prev => (prev.size === 0 ? prev : new Map())); + return; + } + + // Keep the previous page's numbers across a refresh so rows don't flash + // skeletons. A new id set has nothing worth keeping. + if (loadedIdsKeyRef.current !== agentIdsKey) { + loadedIdsKeyRef.current = agentIdsKey; + setUsageByAgentId(prev => (prev.size === 0 ? prev : new Map())); + } + + let cancelled = false; + const now = Date.now(); + const metersEnd = new Date(now).toISOString(); + const metersStart = new Date(now - DEFAULT_SESSION_TIME_WINDOW_MS).toISOString(); + const sparklineStart = new Date(now - SPARKLINE_WINDOW_MS).toISOString(); + + for (const agentId of ids) { + void loadUsageForAgent({ + agentId, + metrics, + sessions, + metersStart, + metersEnd, + sparklineStart, + sparklineEnd: metersEnd, + }) + .then(stats => { + if (cancelled) return; + setUsageByAgentId(prev => { + const next = new Map(prev); + next.set(agentId, stats); + return next; + }); + }) + .catch(() => { + if (cancelled) return; + setUsageByAgentId(prev => { + const next = new Map(prev); + next.set(agentId, unavailableUsage()); + return next; + }); + }); + } + + return () => { + cancelled = true; + }; + }, [agentIdsKey, metrics, refreshKey, sessions]); + + return { usageByAgentId, metricsEnabled, lastRunEnabled }; +} diff --git a/packages/trueforge-ui/src/theme/defaultSlots.ts b/packages/trueforge-ui/src/theme/defaultSlots.ts index b149e6275..20ca3a3e1 100644 --- a/packages/trueforge-ui/src/theme/defaultSlots.ts +++ b/packages/trueforge-ui/src/theme/defaultSlots.ts @@ -30,6 +30,7 @@ import type { AgentSessionEventTimelineProps, AgentSessionsProps, } from '../atoms/agent-details/types.js'; +import { AgentLibraryUsageCells } from '../atoms/AgentLibraryUsage.js'; import { AgentLibraryRow, AgentsLibrary } from '../atoms/AgentsLibrary.js'; import { AgentsLibraryButton } from '../atoms/AgentsLibraryButton.js'; import { ApprovalNavBanner } from '../atoms/ApprovalNavBanner.js'; @@ -225,6 +226,7 @@ export const defaultSlots = { ThreadListNewButton, AgentsLibrary, AgentLibraryRow, + AgentLibraryUsageCells, AgentsLibraryButton, ApprovalNavBanner, SessionsBrowserButton, diff --git a/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx b/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx index 5bcaecbec..c025fcb82 100644 --- a/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx +++ b/packages/trueforge-ui/test/atoms/AgentsLibrary.test.tsx @@ -14,6 +14,7 @@ import { ShellModeProvider, useShellMode } from '@/server/ShellModeContext.js'; import type { AgentUIServer, ListPermissionsResponse } from '@/server/types.js'; import { SlotsProvider } from '@/theme/SlotsProvider.js'; import { + createMockAgentMetricsServer, createMockAgentSessionsServer, createMockAgentUIServer, createMockScheduleServer, @@ -207,7 +208,8 @@ describe('AgentsLibrary', () => { await waitFor(() => { expect(screen.getByRole('button', { name: 'Try agent alpha-agent' })).toBeInTheDocument(); }); - expect(screen.getByText('Alpha handles triage.')).toHaveClass('truncate'); + expect(screen.getByText('gpt-4.1')).toBeInTheDocument(); + expect(screen.getByText('no tools')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Try agent beta-agent' })); expect(onSelectAgent).toHaveBeenCalledWith('beta-agent'); @@ -258,9 +260,12 @@ describe('AgentsLibrary', () => { expect(within(card).getByRole('button', { name: 'Open alpha-agent' })).toBeInTheDocument(); expect(within(card).getByRole('button', { name: 'Try agent alpha-agent' })).toBeInTheDocument(); expect(within(card).getByLabelText('openai/gpt-4.1')).toBeInTheDocument(); - expect(within(card).getByLabelText(/Skills:/)).toBeInTheDocument(); - expect(within(card).getByLabelText(/Connectors:/)).toBeInTheDocument(); - expect(within(card).getByText('Alpha handles triage.')).toHaveClass('line-clamp-2'); + expect(within(card).getByText('1 MCP')).toBeInTheDocument(); + expect(within(card).getByText('1 skill')).toBeInTheDocument(); + expect(within(card).getByLabelText('MCP: Connector')).toBeInTheDocument(); + expect(within(card).getByLabelText('Skills: Skill')).toBeInTheDocument(); + // Description is tooltip-only; creator nests under the name instead. + expect(within(card).queryByText('Alpha handles triage.')).not.toBeInTheDocument(); const addSchedule = await within(card).findByRole('button', { name: 'Add schedule for alpha-agent' }); expect(addSchedule).toBeVisible(); @@ -268,7 +273,7 @@ describe('AgentsLibrary', () => { }); }); - it('truncates long descriptions without hiding Try, and skips name-echo descriptions', async () => { + it('keeps Try visible and skips name-echo descriptions under the agent title', async () => { const longDescription = `${'Lorem ipsum dolor sit amet, '.repeat(20)}consectetur.`; const server = mockServer([ { @@ -291,8 +296,10 @@ describe('AgentsLibrary', () => { await waitFor(() => { expect(screen.getByRole('button', { name: 'Try agent verbose-agent' })).toBeInTheDocument(); }); + expect(screen.getByRole('columnheader', { name: 'Agent' })).toBeInTheDocument(); expect(screen.getByRole('columnheader', { name: 'Configuration' })).toBeInTheDocument(); - expect(screen.getByText(longDescription)).toHaveClass('truncate'); + // Description is tooltip-only on the name; name-echo descriptions stay hidden. + expect(screen.queryByText(longDescription)).not.toBeInTheDocument(); expect(screen.queryByText('echo-agent', { selector: '.text-xs' })).not.toBeInTheDocument(); }); @@ -494,7 +501,7 @@ describe('AgentsLibrary', () => { expect(screen.getByRole('heading', { name: 'Agents' })).toBeInTheDocument(); }); - it('shows model, skills, and connector under a Configuration column with tooltips', async () => { + it('shows model and tools summary under a Configuration column with tooltips', async () => { const server = mockServer([ { name: 'algo-art', @@ -505,6 +512,11 @@ describe('AgentsLibrary', () => { mcpServers: [{ id: 'm1', name: 'github' }], }, }, + { + name: 'model-only', + agentId: 'model-only', + agentSpec: { model: { name: 'openai/gpt-4.1' } }, + }, { name: 'bare', agentId: 'bare' }, ]); @@ -520,8 +532,11 @@ describe('AgentsLibrary', () => { }); expect(screen.getByLabelText('openai/gpt-5-5')).toBeInTheDocument(); expect(screen.getByText('gpt-5-5')).toBeInTheDocument(); - expect(screen.getByLabelText('Connectors: github')).toBeInTheDocument(); + expect(screen.getByText('1 MCP')).toBeInTheDocument(); + expect(screen.getByText('1 skill')).toBeInTheDocument(); + expect(screen.getByLabelText('MCP: github')).toBeInTheDocument(); expect(screen.getByLabelText('Skills: paint')).toBeInTheDocument(); + expect(screen.getByText('no tools')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Try agent bare' })).toBeInTheDocument(); expect(screen.getByLabelText('Configuration unavailable for bare')).toBeInTheDocument(); }); @@ -906,7 +921,7 @@ describe('AgentsLibraryButton', () => { }); }); - it('shows Created by when agents include createdBySubject', async () => { + it('nests creator under the agent name when createdBySubject is present', async () => { const server = mockServer([ { name: 'alpha-agent', @@ -921,15 +936,125 @@ describe('AgentsLibraryButton', () => { renderLibrary(, { server }); fireEvent.click(screen.getByRole('button', { name: 'Open library' })); - expect(await screen.findByRole('columnheader', { name: 'Created by' })).toBeInTheDocument(); + expect(await screen.findByRole('columnheader', { name: 'Agent' })).toBeInTheDocument(); + expect(screen.queryByRole('columnheader', { name: 'Created by' })).not.toBeInTheDocument(); expect(screen.getByText('alice@example.com')).toBeInTheDocument(); expect(document.querySelector('[data-slot="avatar-fallback"]')).toHaveTextContent(/^A$/); }); - it('hides Created by when no agent has createdBySubject', async () => { + it('omits creator chrome when no agent has createdBySubject', async () => { renderLibrary(, { server: mockServer() }); fireEvent.click(screen.getByRole('button', { name: 'Open library' })); await screen.findByRole('button', { name: 'Try agent alpha-agent' }); expect(screen.queryByRole('columnheader', { name: 'Created by' })).not.toBeInTheDocument(); + expect(document.querySelector('[data-slot="avatar-fallback"]')).not.toBeInTheDocument(); + }); + + it('shows sessions, spend, sparkline, and last run when metrics and sessions are available', async () => { + const getMeters = vi.fn(async () => [ + { name: 'total_sessions', aggregateValue: 12, description: 'Total sessions', unit: 'count' }, + { name: 'total_cost_in_usd', aggregateValue: 4.5, description: 'Total cost', unit: '$' }, + { name: 'cost_per_session_in_usd', aggregateValue: 0.375, description: 'Cost per session', unit: '$' }, + ]); + const getChartData = vi.fn(async () => ({ + step: '86400', + graphs: [ + { + name: 'sessions_cost_over_time', + displayName: 'Cost', + description: 'Daily spend', + unit: '$', + chartType: 'line' as const, + graphLines: [ + { + name: 'cost', + values: [ + { timestamp: '2026-10-01T00:00:00.000Z', value: 0.1 }, + { timestamp: '2026-10-02T00:00:00.000Z', value: 0.4 }, + { timestamp: '2026-10-03T00:00:00.000Z', value: 0.2 }, + ], + }, + ], + }, + ], + })); + const listSessions = vi.fn(async () => ({ + data: [ + { + id: 'sess-1', + createdAt: '2026-10-18T12:00:00.000Z', + updatedAt: '2026-10-18T12:30:00.000Z', + lastActivityAt: '2026-10-18T12:30:00.000Z', + metrics: { totalTurns: 1, totalDurationMs: 1000 }, + }, + ], + })); + const server = createMockAgentUIServer({ + searchAgents: vi.fn(async () => ({ + data: [{ name: 'metrics-agent', agentId: 'metrics-agent', agentSpec: { model: { name: 'openai/gpt-4.1' } } }], + })), + sessions: createMockAgentSessionsServer({ listSessions }), + metrics: createMockAgentMetricsServer({ getMeters, getChartData }), + }); + + renderLibrary(, { server }); + fireEvent.click(screen.getByRole('button', { name: 'Open library' })); + + expect(await screen.findByRole('columnheader', { name: 'Sessions, 30d' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Spend, 30d' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Daily, 14d' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Last run' })).toBeInTheDocument(); + + await waitFor(() => { + expect(getMeters).toHaveBeenCalled(); + expect(getChartData).toHaveBeenCalled(); + expect(listSessions).toHaveBeenCalledWith({ agentId: 'metrics-agent', limit: 1, order: 'desc' }); + }); + + expect(await screen.findByLabelText('12 sessions')).toBeInTheDocument(); + expect(screen.getByText('$4.50')).toBeInTheDocument(); + expect(screen.getByText('$0.38 / session')).toBeInTheDocument(); + const sparkline = screen.getByRole('img', { name: 'Daily spend over 14 days for metrics-agent' }); + expect(sparkline).toHaveClass('text-success-bg'); + fireEvent.mouseMove(sparkline, { clientX: 0, clientY: 8 }); + const utcDay = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }).format(new Date('2026-10-01T00:00:00.000Z')); + expect(await screen.findByRole('tooltip')).toHaveTextContent(utcDay); + expect(screen.getByRole('tooltip')).toHaveTextContent('$0.10'); + expect(screen.getByLabelText(/Last run for metrics-agent:/)).toBeInTheDocument(); + }); + + it('shows Never when the agent has no sessions', async () => { + const server = createMockAgentUIServer({ + searchAgents: vi.fn(async () => ({ + data: [{ name: 'metrics-agent', agentId: 'metrics-agent' }], + })), + sessions: createMockAgentSessionsServer({ + listSessions: vi.fn(async () => ({ data: [] })), + }), + }); + renderLibrary(, { server }); + fireEvent.click(screen.getByRole('button', { name: 'Open library' })); + expect(await screen.findByLabelText('Last run for metrics-agent: Never')).toBeInTheDocument(); + }); + + it('shows an em dash for last run when the session list fails', async () => { + const server = createMockAgentUIServer({ + searchAgents: vi.fn(async () => ({ + data: [{ name: 'metrics-agent', agentId: 'metrics-agent' }], + })), + sessions: createMockAgentSessionsServer({ + listSessions: vi.fn(async () => { + throw new Error('sessions down'); + }), + }), + }); + renderLibrary(, { server }); + fireEvent.click(screen.getByRole('button', { name: 'Open library' })); + expect(await screen.findByLabelText('Last run unavailable for metrics-agent')).toHaveTextContent('—'); + expect(screen.queryByText('Never')).not.toBeInTheDocument(); }); });