refactor(ui): migrate agents to shadcn (#34365)

* test(ui): make the agents route's tests markup-agnostic before migration

Rewrites the two assertions that were coupled to antd's DOM and adds the
missing characterisation test for agent_cost_view, so the suite describes
behaviour rather than antd markup and can stay untouched across the shadcn
migration.

The skill selection test reached the checkbox with a querySelector on
input[type=checkbox]; antd renders an input while Base UI renders a
span[role=checkbox], so it now queries by role and accessible name, which
both libraries derive from the wrapping label.

The delete confirmation test queried role=dialog; antd Modal is a dialog
while Base UI AlertDialog is an alertdialog, so it now anchors on the
confirmation text and accepts either role.

agent_cost_view had no test at all; it gets one covering the null render,
the dollar-prefixed values, the omitted rows, and a zero cost that must not
be mistaken for unset.

All 55 tests pass against the current antd components.

* refactor(ui): migrate agents to shadcn

Replaces antd and Tremor with shadcn (base-vega) primitives across the five
files the agents route exclusively owns. Markup only; no behaviour, data
fetching or route structure changes.

Modal becomes AlertDialog, with a plain destructive Button in the footer
rather than AlertDialogAction, because that action is AlertDialog.Close and
would dismiss the dialog before the delete request settles, losing the
in-flight state. Alert, Tag, Spin, Space, Collapse, Descriptions, Typography
and the antd icons map onto alert, badge, ui-loading-spinner, flex/grid
utilities, collapsible, a definition list, semantic headings and lucide.

The shadcn CLI emits alert.tsx importing cva from class-variance-authority,
which this project does not depend on; it uses the cva object syntax from
lib/cva.config. The generated file fails to typecheck, so the adapted copy
lives in components/shared instead, per the convention that ui/ stays
CLI-managed.

Colour comes from tokens throughout, so the info callout is now the neutral
card style rather than antd's blue, and nothing hardcodes a colour in the way
of a later theme change.

The 55 tests in the route pass unchanged from the previous commit. The visual
gate re-baselined agents and all 34 other routes stayed pixel-identical.
This commit is contained in:
yuneng-jiang 2026-07-23 16:35:56 -07:00 committed by GitHub
parent 43e7b96b83
commit 07726b4f60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 402 additions and 255 deletions

View file

@ -37,16 +37,6 @@
"count": 1
}
},
"src/app/(dashboard)/agents/_components/AgentsPanel.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/agents/_components/AgentsTable.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/agents/_components/add_agent_form.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -71,9 +61,6 @@
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/refs": {
"count": 3
},
@ -84,9 +71,6 @@
"src/app/(dashboard)/agents/_components/agent_cost_view.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/agents/_components/agent_form_fields.tsx": {
@ -123,9 +107,6 @@
},
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/agents/_components/cost_config_fields.tsx": {

View file

@ -140,8 +140,9 @@ describe("AgentsPanel", () => {
await user.click(await screen.findByTestId("agent-actions-agent-9"));
await user.click(await screen.findByTestId("agent-action-delete"));
const modal = await screen.findByRole("dialog");
await user.click(within(modal).getByRole("button", { name: /^delete$/i }));
const confirmPrompt = await screen.findByText(/are you sure you want to delete agent: Doomed Agent\?/i);
const confirmDialog = confirmPrompt.closest('[role="dialog"],[role="alertdialog"]') as HTMLElement;
await user.click(within(confirmDialog).getByRole("button", { name: /^delete$/i }));
await waitFor(() => {
expect(networking.deleteAgentCall).toHaveBeenCalledWith("test-token", "agent-9");

View file

@ -1,6 +1,5 @@
import React, { useState, useEffect } from "react";
import { Modal, Alert } from "antd";
import { Plus } from "lucide-react";
import { Info, Plus } from "lucide-react";
import { getAgentsList, deleteAgentCall } from "@/components/networking";
import AddAgentForm from "./add_agent_form";
import { isAdminRole } from "@/utils/roles";
@ -9,6 +8,16 @@ import AgentsTable from "./AgentsTable";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { Agent } from "@/components/agents/types";
import { Team } from "@/components/key_team_helpers/key_list";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
interface AgentsPanelProps {
@ -130,17 +139,18 @@ const AgentsPanel: React.FC<AgentsPanelProps> = ({ accessToken, userRole, teams
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
<div className="flex flex-col gap-2 mb-4">
<h1 className="text-2xl font-bold">Agents</h1>
<p className="text-sm text-gray-600">
<p className="text-sm text-muted-foreground">
List of A2A-spec agents that are available to be used in your organization. Go to AI Hub, to make agents
public.
</p>
<Alert
message="Why do agents need keys?"
description="Keys scope access to an agent and allow it to call MCP tools. Assign a key when creating an agent or from the Virtual Keys page."
type="info"
showIcon
className="mb-3"
/>
<Alert className="mb-3">
<Info />
<AlertTitle>Why do agents need keys?</AlertTitle>
<AlertDescription>
Keys scope access to an agent and allow it to call MCP tools. Assign a key when creating an agent or from
the Virtual Keys page.
</AlertDescription>
</Alert>
{isAdmin && (
<div className="mt-2 flex items-center gap-4">
<Button onClick={handleAddAgent} disabled={!accessToken}>
@ -180,18 +190,27 @@ const AgentsPanel: React.FC<AgentsPanelProps> = ({ accessToken, userRole, teams
/>
{agentToDelete && (
<Modal
title="Delete Agent"
open={agentToDelete !== null}
onOk={handleDeleteConfirm}
onCancel={handleDeleteCancel}
confirmLoading={isDeleting}
okText="Delete"
okButtonProps={{ danger: true }}
<AlertDialog
open
onOpenChange={(open) => {
if (!open) handleDeleteCancel();
}}
>
<p>Are you sure you want to delete agent: {agentToDelete.name}?</p>
<p>This action cannot be undone.</p>
</Modal>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Agent</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete agent: {agentToDelete.name}? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button variant="destructive" onClick={handleDeleteConfirm} disabled={isDeleting}>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
);

View file

@ -1,13 +1,13 @@
"use client";
import { SortingState } from "@tanstack/react-table";
import { Tooltip, Switch } from "antd";
import { CheckCircleOutlined } from "@ant-design/icons";
import { Bot } from "lucide-react";
import { Bot, CircleCheck } from "lucide-react";
import React, { useMemo, useState } from "react";
import { Agent } from "@/components/agents/types";
import { DataTable } from "@/components/shared/DataTable";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { getAgentsTableColumns } from "./AgentsTableColumns";
@ -67,18 +67,27 @@ const AgentsTable: React.FC<AgentsTableProps> = ({
size="compact"
toolbar={() => (
<div className="flex items-center justify-end">
<Tooltip title="When enabled, only agents with reachable URLs are shown">
<div className="flex items-center gap-2">
<CheckCircleOutlined className={healthCheckEnabled ? "text-green-500" : "text-muted-foreground"} />
<span className="text-sm text-muted-foreground">Health Check</span>
<Switch
size="small"
checked={healthCheckEnabled}
onChange={onHealthCheckToggle}
loading={isHealthCheckLoading}
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger
render={
<div className="flex items-center gap-2">
<CircleCheck
className={healthCheckEnabled ? "size-4 text-green-500" : "size-4 text-muted-foreground"}
/>
<span className="text-sm text-muted-foreground">Health Check</span>
<Switch
size="sm"
checked={healthCheckEnabled}
onCheckedChange={onHealthCheckToggle}
disabled={isHealthCheckLoading}
/>
</div>
}
/>
</div>
</Tooltip>
<TooltipContent>When enabled, only agents with reachable URLs are shown</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
/>

View file

@ -126,10 +126,7 @@ describe("AgentCardDiscovery", () => {
expect(initialSelection.upstream_url).toBe("https://upstream.example.com");
expect(initialSelection.selected_card.skills).toHaveLength(2);
const summarizeLabel = screen.getByText("Summarize").closest("label");
expect(summarizeLabel).toBeTruthy();
const summarizeCheckbox = summarizeLabel!.querySelector("input[type='checkbox']") as HTMLInputElement;
await user.click(summarizeCheckbox);
await user.click(screen.getByRole("checkbox", { name: /Summarize/i }));
await waitFor(() => {
const latest = onApply.mock.calls.at(-1)?.[0];

View file

@ -1,18 +1,20 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, Button, Checkbox, Collapse, Empty, Input, Space, Spin, Switch, Tag, Tooltip, Typography } from "antd";
// Empty is used in the skills panel below.
import {
CheckCircleTwoTone,
InfoCircleOutlined,
LinkOutlined,
ReloadOutlined,
SearchOutlined,
} from "@ant-design/icons";
import { ChevronDown, CircleAlert, CircleCheck, Info, Link as LinkIcon, RotateCw, Search, X } from "lucide-react";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { DiscoveredAgentCard, discoverAgentCardCall } from "@/components/networking";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import {
ALLOWED_CAPABILITY_KEYS,
selectionsFromSavedAgentCard,
@ -20,9 +22,6 @@ import {
skillId,
} from "./agent_discovery_utils";
const { Text, Paragraph } = Typography;
const { Panel } = Collapse;
const DISCOVERY_DEBOUNCE_WAIT_MS = 400;
export interface DiscoveredAgentCardSelection {
@ -243,102 +242,115 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
const skillCount = card?.skills?.length ?? 0;
const selectedSkillCount = selectedSkillIds.size;
const renderDiscoverIcon = () => {
if (loading) return <UiLoadingSpinner className="size-4" />;
if (card) return <RotateCw />;
return <Search />;
};
const discoverLabel = card ? "Re-discover" : "Discover";
return (
<div className="border border-gray-200 rounded-lg p-4 bg-gray-50 mb-4">
<div className="flex items-center gap-2 mb-2">
<LinkOutlined className="text-indigo-600" />
<Text strong>Discover from agent URL</Text>
<Tooltip title="LiteLLM will fetch /.well-known/agent-card.json from this URL and let you pick which skills and capabilities to expose through the proxy.">
<InfoCircleOutlined className="text-gray-400" />
</Tooltip>
<div className="mb-4 rounded-lg border border-border bg-muted/50 p-4">
<div className="mb-2 flex items-center gap-2">
<LinkIcon className="size-4 text-primary" />
<span className="text-sm font-medium text-foreground">Discover from agent URL</span>
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger
render={
<span className="inline-flex text-muted-foreground">
<Info className="size-4" />
</span>
}
/>
<TooltipContent>
LiteLLM will fetch /.well-known/agent-card.json from this URL and let you pick which skills and
capabilities to expose through the proxy.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{isParentDriven ? (
<>
<Paragraph className="text-xs text-gray-500 mb-2">
<p className="mb-2 text-xs text-muted-foreground">
Using the connection details you entered above. We&apos;ll fetch:
</Paragraph>
<div className="bg-white border border-gray-200 rounded-sm px-3 py-2 mb-3 font-mono text-xs text-gray-700 break-all">
</p>
<div className="mb-3 rounded-sm border border-border bg-background px-3 py-2 font-mono text-xs break-all text-foreground">
{discoveryRequest!.display_url || effectiveUrl || (
<span className="text-gray-400 italic">Fill in the fields above first</span>
<span className="text-muted-foreground italic">Fill in the fields above first</span>
)}
</div>
<div className="flex justify-end">
<Button
type="primary"
icon={card ? <ReloadOutlined /> : <SearchOutlined />}
loading={loading}
onClick={handleDiscover}
disabled={!effectiveUrl.trim()}
>
{card ? "Re-discover" : "Discover"}
<Button onClick={handleDiscover} disabled={loading || !effectiveUrl.trim()}>
{renderDiscoverIcon()}
{discoverLabel}
</Button>
</div>
</>
) : (
<>
<Paragraph className="text-xs text-gray-500 mb-3">
<p className="mb-3 text-xs text-muted-foreground">
Paste the upstream agent&apos;s base URL. We&apos;ll try <code>/.well-known/agent-card.json</code>,{" "}
<code>/.well-known/agent.json</code>, and <code>/agent.json</code> in order.
</Paragraph>
</p>
<Space.Compact style={{ width: "100%" }}>
<div className="flex w-full items-center gap-2">
<Input
placeholder="https://upstream-agent.example.com"
value={manualUrl}
onChange={(e) => setManualUrl(e.target.value)}
onPressEnter={handleDiscover}
allowClear
onKeyDown={(e) => {
if (e.key === "Enter") handleDiscover();
}}
disabled={loading}
/>
<Button
type="primary"
icon={card ? <ReloadOutlined /> : <SearchOutlined />}
loading={loading}
onClick={handleDiscover}
>
{card ? "Re-discover" : "Discover"}
<Button onClick={handleDiscover} disabled={loading}>
{renderDiscoverIcon()}
{discoverLabel}
</Button>
</Space.Compact>
</div>
</>
)}
{error && (
<Alert
className="mt-3"
type="error"
message="Discovery failed"
description={error}
showIcon
closable
onClose={() => setError(null)}
/>
<Alert variant="destructive" className="mt-3">
<CircleAlert />
<AlertTitle>Discovery failed</AlertTitle>
<AlertDescription>{error}</AlertDescription>
<AlertAction>
<Button variant="ghost" size="icon-xs" aria-label="Dismiss error" onClick={() => setError(null)}>
<X />
</Button>
</AlertAction>
</Alert>
)}
{loading && !card && (
<div className="flex items-center justify-center py-8">
<Spin />
<UiLoadingSpinner className="size-6 text-muted-foreground" />
</div>
)}
{card && (
<div className="mt-4 bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<Space>
<CheckCircleTwoTone twoToneColor="#52c41a" />
<Text strong>Upstream card loaded</Text>
{card.version && <Tag color="blue">v{card.version}</Tag>}
{card.provider?.organization && <Tag color="purple">{card.provider.organization}</Tag>}
</Space>
<div className="mt-4 rounded-lg border border-border bg-background p-4">
<div className="mb-3 flex flex-wrap items-center gap-2">
<CircleCheck className="size-4 text-green-600" />
<span className="text-sm font-medium text-foreground">Upstream card loaded</span>
{card.version && <Badge variant="secondary">v{card.version}</Badge>}
{card.provider?.organization && <Badge variant="secondary">{card.provider.organization}</Badge>}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
<div className="mb-4 grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label className="text-xs font-medium text-gray-600 block mb-1">Name (shown to API clients)</label>
<label className="mb-1 block text-xs font-medium text-muted-foreground">
Name (shown to API clients)
</label>
<Input value={editedName} onChange={(e) => setEditedName(e.target.value)} placeholder="Agent name" />
</div>
<div>
<label className="text-xs font-medium text-gray-600 block mb-1">Description</label>
<Input.TextArea
<label className="mb-1 block text-xs font-medium text-muted-foreground">Description</label>
<Textarea
className="field-sizing-fixed min-h-0"
value={editedDescription}
onChange={(e) => setEditedDescription(e.target.value)}
rows={2}
@ -347,103 +359,114 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
</div>
</div>
<Collapse defaultActiveKey={["skills", "capabilities"]} ghost className="bg-transparent">
<Panel
key="skills"
header={
<Space>
<Text strong>Skills</Text>
<Tag>
{selectedSkillCount} / {skillCount} selected
</Tag>
</Space>
}
>
{skillCount === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="Upstream card has no skills" />
) : (
<div className="space-y-2">
{(card.skills ?? []).map((skill, idx) => {
const id = skillId(skill, idx);
const checked = selectedSkillIds.has(id);
return (
<label
key={id}
className={`flex items-start gap-3 p-3 border rounded cursor-pointer transition-colors ${
checked ? "border-indigo-300 bg-indigo-50" : "border-gray-200 bg-white hover:border-gray-300"
}`}
>
<Checkbox checked={checked} onChange={(e) => toggleSkill(id, e.target.checked)} />
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<Text strong>{skill.name || id}</Text>
{skill.id && <Tag style={{ marginLeft: 0 }}>{skill.id}</Tag>}
{(skill.tags ?? []).map((t: string) => (
<Tag key={t} color="geekblue">
{t}
</Tag>
))}
<div className="flex flex-col gap-4">
<Collapsible defaultOpen>
<div className="flex items-center gap-2">
<CollapsibleTrigger
render={
<button type="button" className="group flex items-center gap-2">
<ChevronDown className="size-4 text-muted-foreground transition-transform group-data-[panel-open]:rotate-180" />
<span className="text-sm font-medium text-foreground">Skills</span>
</button>
}
/>
<Badge variant="secondary">
{selectedSkillCount} / {skillCount} selected
</Badge>
</div>
<CollapsibleContent className="pt-2">
{skillCount === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">Upstream card has no skills</div>
) : (
<div className="space-y-2">
{(card.skills ?? []).map((skill, idx) => {
const id = skillId(skill, idx);
const checked = selectedSkillIds.has(id);
return (
<label
key={id}
className={`flex cursor-pointer items-start gap-3 rounded border p-3 transition-colors ${
checked ? "border-primary/40 bg-primary/5" : "border-border bg-background hover:border-ring"
}`}
>
<Checkbox checked={checked} onCheckedChange={(next) => toggleSkill(id, next)} />
<div className="flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground">{skill.name || id}</span>
{skill.id && <Badge variant="secondary">{skill.id}</Badge>}
{(skill.tags ?? []).map((t: string) => (
<Badge key={t} variant="outline">
{t}
</Badge>
))}
</div>
{skill.description && (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{skill.description}</p>
)}
</div>
{skill.description && (
<Paragraph
className="text-xs text-gray-500 mt-1 mb-0"
ellipsis={{ rows: 2, expandable: true, symbol: "more" }}
>
{skill.description}
</Paragraph>
)}
</label>
);
})}
</div>
)}
</CollapsibleContent>
</Collapsible>
<Collapsible defaultOpen>
<div className="flex items-center gap-2">
<CollapsibleTrigger
render={
<button type="button" className="group flex items-center gap-2">
<ChevronDown className="size-4 text-muted-foreground transition-transform group-data-[panel-open]:rotate-180" />
<span className="text-sm font-medium text-foreground">Capabilities</span>
</button>
}
/>
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger
render={
<span className="inline-flex text-muted-foreground">
<Info className="size-4" />
</span>
}
/>
<TooltipContent>
Only capabilities LiteLLM can faithfully proxy today are listed. Others (push notifications,
extensions) are coming soon.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<CollapsibleContent className="pt-2">
<div className="space-y-2">
{ALLOWED_CAPABILITY_KEYS.map((key) => {
const upstreamHas = Boolean(card.capabilities?.[key]);
return (
<div
key={key}
className="flex items-center justify-between rounded-sm border border-border bg-background p-2"
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground capitalize">{key}</span>
{!upstreamHas && <Badge variant="outline">not advertised upstream</Badge>}
</div>
</label>
<Switch
checked={Boolean(selectedCapabilities[key])}
onCheckedChange={(checked) =>
setSelectedCapabilities((prev) => ({
...prev,
[key]: checked,
}))
}
/>
</div>
);
})}
</div>
)}
</Panel>
<Panel
key="capabilities"
header={
<Space>
<Text strong>Capabilities</Text>
<Tooltip title="Only capabilities LiteLLM can faithfully proxy today are listed. Others (push notifications, extensions) are coming soon.">
<InfoCircleOutlined className="text-gray-400" />
</Tooltip>
</Space>
}
>
<div className="space-y-2">
{ALLOWED_CAPABILITY_KEYS.map((key) => {
const upstreamHas = Boolean(card.capabilities?.[key]);
return (
<div
key={key}
className="flex items-center justify-between p-2 border border-gray-200 rounded-sm bg-white"
>
<div>
<Text strong className="capitalize">
{key}
</Text>
{!upstreamHas && (
<Tag className="ml-2" color="default">
not advertised upstream
</Tag>
)}
</div>
<Switch
checked={Boolean(selectedCapabilities[key])}
onChange={(checked) =>
setSelectedCapabilities((prev) => ({
...prev,
[key]: checked,
}))
}
/>
</div>
);
})}
</div>
</Panel>
</Collapse>
</CollapsibleContent>
</Collapsible>
</div>
</div>
)}
</div>

View file

@ -0,0 +1,54 @@
import React from "react";
import { describe, it, expect } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithProviders } from "@/../tests/test-utils";
import AgentCostView from "./agent_cost_view";
import type { Agent } from "@/components/agents/types";
const makeAgent = (litellmParams: Agent["litellm_params"]): Agent => ({
agent_id: "agent-1",
agent_name: "Test Agent",
litellm_params: litellmParams,
});
describe("AgentCostView", () => {
it("renders nothing when the agent has no cost configuration at all", () => {
const { container } = renderWithProviders(<AgentCostView agent={makeAgent({ model: "gpt-4" })} />);
expect(container).toBeEmptyDOMElement();
});
it("renders every configured cost with a dollar-prefixed value", () => {
const fullyPricedParams = {
model: "gpt-4",
cost_per_query: 0.05,
input_cost_per_token: 0.000012,
output_cost_per_token: 0.000034,
};
renderWithProviders(<AgentCostView agent={makeAgent(fullyPricedParams)} />);
expect(screen.getByText("Cost Configuration")).toBeInTheDocument();
expect(screen.getByText("Cost Per Query")).toBeInTheDocument();
expect(screen.getByText("$0.05")).toBeInTheDocument();
expect(screen.getByText("Input Cost Per Token")).toBeInTheDocument();
expect(screen.getByText("$0.000012")).toBeInTheDocument();
expect(screen.getByText("Output Cost Per Token")).toBeInTheDocument();
expect(screen.getByText("$0.000034")).toBeInTheDocument();
});
it("omits the rows whose cost is not configured", () => {
renderWithProviders(<AgentCostView agent={makeAgent({ model: "gpt-4", cost_per_query: 0.25 })} />);
expect(screen.getByText("Cost Per Query")).toBeInTheDocument();
expect(screen.getByText("$0.25")).toBeInTheDocument();
expect(screen.queryByText("Input Cost Per Token")).not.toBeInTheDocument();
expect(screen.queryByText("Output Cost Per Token")).not.toBeInTheDocument();
});
it("still renders a zero cost rather than treating it as unset", () => {
renderWithProviders(<AgentCostView agent={makeAgent({ model: "gpt-4", cost_per_query: 0 })} />);
expect(screen.getByText("Cost Configuration")).toBeInTheDocument();
expect(screen.getByText("Cost Per Query")).toBeInTheDocument();
expect(screen.getByText("$0")).toBeInTheDocument();
});
});

View file

@ -1,6 +1,4 @@
import React from "react";
import { Title } from "@tremor/react";
import { Descriptions } from "antd";
import { Agent } from "@/components/agents/types";
interface AgentCostViewProps {
@ -18,20 +16,25 @@ const AgentCostView: React.FC<AgentCostViewProps> = ({ agent }) => {
return null;
}
const rows = (
[
["Cost Per Query", params.cost_per_query],
["Input Cost Per Token", params.input_cost_per_token],
["Output Cost Per Token", params.output_cost_per_token],
] as const
).filter(([, value]) => value !== undefined);
return (
<div style={{ marginTop: 24 }}>
<Title>Cost Configuration</Title>
<Descriptions bordered column={1} style={{ marginTop: 16 }}>
{params.cost_per_query !== undefined && (
<Descriptions.Item label="Cost Per Query">${params.cost_per_query}</Descriptions.Item>
)}
{params.input_cost_per_token !== undefined && (
<Descriptions.Item label="Input Cost Per Token">${params.input_cost_per_token}</Descriptions.Item>
)}
{params.output_cost_per_token !== undefined && (
<Descriptions.Item label="Output Cost Per Token">${params.output_cost_per_token}</Descriptions.Item>
)}
</Descriptions>
<div className="mt-6">
<h3 className="text-lg font-semibold text-foreground">Cost Configuration</h3>
<dl className="mt-4 divide-y divide-border overflow-hidden rounded-lg border border-border">
{rows.map(([label, value]) => (
<div key={label} className="grid grid-cols-1 sm:grid-cols-3">
<dt className="bg-muted/50 px-4 py-3 text-sm font-medium text-foreground">{label}</dt>
<dd className="px-4 py-3 text-sm text-foreground sm:col-span-2">${value}</dd>
</div>
))}
</dl>
</div>
);
};

View file

@ -1,9 +1,8 @@
import React from "react";
import { Button, Tooltip, Typography } from "antd";
import { KeyOutlined } from "@ant-design/icons";
import { KeyRound } from "lucide-react";
import { KeyResponse } from "@/components/key_team_helpers/key_list";
const { Title, Text } = Typography;
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
interface AgentVirtualKeysProps {
keys: KeyResponse[];
@ -13,29 +12,31 @@ interface AgentVirtualKeysProps {
const AgentVirtualKeys: React.FC<AgentVirtualKeysProps> = ({ keys, isLoading, onKeyClick }) => {
return (
<div style={{ marginTop: 24 }}>
<Title level={4}>Virtual Keys</Title>
<div className="mt-6">
<h4 className="text-base font-semibold text-foreground">Virtual Keys</h4>
{isLoading ? (
<Text className="mt-2 block">Loading keys...</Text>
<p className="mt-2 text-sm text-muted-foreground">Loading keys...</p>
) : keys.length === 0 ? (
<Text className="mt-2 block text-gray-500">No virtual key assigned to this agent.</Text>
<p className="mt-2 text-sm text-muted-foreground">No virtual key assigned to this agent.</p>
) : (
<div className="mt-3 flex flex-col gap-2">
{keys.map((key) => (
<div key={key.token} className="flex items-center gap-3 border border-gray-100 rounded-sm px-3 py-2">
<KeyOutlined className="text-gray-400" />
<span className="font-medium">{key.key_alias || "Unnamed key"}</span>
{key.key_name && <span className="font-mono text-xs text-gray-500">{key.key_name}</span>}
<Tooltip title={key.token}>
<Button
size="small"
type="link"
className="font-mono text-blue-500 ml-auto"
onClick={() => onKeyClick(key)}
>
{key.token?.slice(0, 12)}...
</Button>
</Tooltip>
<div key={key.token} className="flex items-center gap-3 rounded-sm border border-border px-3 py-2">
<KeyRound className="size-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">{key.key_alias || "Unnamed key"}</span>
{key.key_name && <span className="font-mono text-xs text-muted-foreground">{key.key_name}</span>}
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger
render={
<Button variant="link" size="sm" className="ml-auto font-mono" onClick={() => onKeyClick(key)}>
{key.token?.slice(0, 12)}...
</Button>
}
/>
<TooltipContent>{key.token}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
))}
</div>

View file

@ -0,0 +1,59 @@
import * as React from "react";
import { type VariantProps } from "cva";
import { cn, cva } from "@/lib/cva.config";
const alertVariants = cva({
base: "group/alert relative grid w-full gap-0.5 rounded-lg border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive: "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
});
type AlertProps = React.ComponentProps<"div"> & VariantProps<typeof alertVariants>;
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(({ className, variant, ...props }, ref) => (
<div ref={ref} data-slot="alert" role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className,
)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className,
)}
{...props}
/>
),
);
AlertDescription.displayName = "AlertDescription";
const AlertAction = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => (
<div ref={ref} data-slot="alert-action" className={cn("absolute top-2.5 right-3", className)} {...props} />
));
AlertAction.displayName = "AlertAction";
export { Alert, AlertTitle, AlertDescription, AlertAction };