feat(ui): Projects keys section + playground compareUI to shadcn

- Projects/ProjectKeysSection: antd Card/Flex/Input/Spin/Pagination +
  @ant-design LoadingOutlined \u2192 shadcn Card/Input + lucide Key/
  Search + lightweight prev/next pager using ChevronLeft/Right.
- Projects/ProjectKeysTable: antd Table + Empty + Tooltip \u2192 shadcn
  Table + Tooltip + lucide Loader2 for the loading state. Emits a
  simple empty row when no keys match.
- playground/compareUI/MessageInput: antd Input.TextArea + Button +
  @ant-design ArrowUpOutlined \u2192 shadcn Textarea + Button (icon
  variant, rounded-full) + lucide ArrowUp.
- playground/compareUI/UnifiedSelector: antd Select + Spin \u2192 shadcn
  Select + lucide Loader2 for the loading placeholder inside the
  content surface.

Gates: TS \u2713 | Build \u2713.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-23 11:31:59 +00:00
parent b1f9b8bf09
commit 55ca23278a
No known key found for this signature in database
4 changed files with 198 additions and 128 deletions

View file

@ -1,7 +1,13 @@
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { LoadingOutlined } from "@ant-design/icons";
import { Card, Flex, Input, Pagination, Spin } from "antd";
import { KeyIcon, SearchIcon } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
ChevronLeft,
ChevronRight,
Key as KeyIcon,
Search,
} from "lucide-react";
import { useEffect, useState } from "react";
import { ProjectKeysTable } from "./ProjectKeysTable";
@ -20,48 +26,62 @@ export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
selectedKeyAlias: keyAlias || null,
});
// Reset to page 1 when filter changes
useEffect(() => {
setPage(1);
}, [keyAlias]);
const keys = data?.keys ?? [];
const totalCount = data?.total_count ?? 0;
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
return (
<Card
title={
<Flex align="center" gap={8}>
<Card className="h-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyIcon size={16} />
Keys
</Flex>
}
style={{ height: "100%" }}
>
<Flex justify="space-between" align="center" style={{ marginBottom: 12 }}>
<Input
prefix={<SearchIcon size={14} />}
placeholder="Filter by key name..."
style={{ maxWidth: 220 }}
value={keyAlias}
onChange={(e) => setKeyAlias(e.target.value)}
allowClear
size="small"
/>
<Pagination
current={page}
total={totalCount}
pageSize={PAGE_SIZE}
onChange={setPage}
size="small"
showSizeChanger={false}
showTotal={(total) => `${total} keys`}
/>
</Flex>
<ProjectKeysTable
keys={keys}
loading={isLoading ? { indicator: <Spin indicator={<LoadingOutlined spin />} /> } : false}
/>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex justify-between items-center mb-3 gap-2">
<div className="relative max-w-[220px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input
placeholder="Filter by key name..."
className="pl-7 h-8"
value={keyAlias}
onChange={(e) => setKeyAlias(e.target.value)}
/>
</div>
<div className="flex items-center gap-1 text-sm">
<span className="text-muted-foreground">{totalCount} keys</span>
<Button
size="icon"
variant="ghost"
className="h-6 w-6"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
aria-label="Previous page"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-muted-foreground">
{page}/{totalPages}
</span>
<Button
size="icon"
variant="ghost"
className="h-6 w-6"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
aria-label="Next page"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<ProjectKeysTable keys={keys} loading={isLoading} />
</CardContent>
</Card>
);
}

View file

@ -1,58 +1,93 @@
import { KeyResponse } from "@/components/key_team_helpers/key_list";
import { Empty, Table, Tooltip } from "antd";
import type { ColumnsType } from "antd/es/table";
import type { SpinProps } from "antd";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Loader2 } from "lucide-react";
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
interface ProjectKeysTableProps {
keys: KeyResponse[];
loading?: boolean | SpinProps;
loading?: boolean;
}
const columns: ColumnsType<KeyResponse> = [
{
title: "Key Name",
dataIndex: "key_alias",
key: "key_alias",
render: (alias: string | null) => alias || "—",
},
{
title: "Owner",
key: "owner",
render: (_: unknown, record: KeyResponse) => {
const email = record.user?.user_email ?? record.user_id ?? null;
if (!email) return "—";
return (
<Tooltip title={email}>
<DefaultProxyAdminTag userId={email} />
</Tooltip>
);
},
},
{
title: "Created",
dataIndex: "created_at",
key: "created_at",
render: (date: string) => (date ? new Date(date).toLocaleDateString() : "—"),
},
{
title: "Last Active",
dataIndex: "last_active",
key: "last_active",
render: (date: string | null) => (date ? new Date(date).toLocaleDateString() : "Never"),
},
];
export function ProjectKeysTable({ keys, loading }: ProjectKeysTableProps) {
return (
<Table
columns={columns}
dataSource={keys}
rowKey="token"
loading={loading}
pagination={false}
size="small"
locale={{ emptyText: <Empty description="No keys found" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
/>
<div className="border border-border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Key Name</TableHead>
<TableHead>Owner</TableHead>
<TableHead>Created</TableHead>
<TableHead>Last Active</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-6">
<Loader2 className="h-4 w-4 animate-spin mx-auto text-muted-foreground" />
</TableCell>
</TableRow>
) : keys.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
className="text-center py-6 text-muted-foreground"
>
No keys found
</TableCell>
</TableRow>
) : (
keys.map((k) => {
const alias = k.key_alias || "—";
const email = k.user?.user_email ?? k.user_id ?? null;
return (
<TableRow key={k.token}>
<TableCell>{alias}</TableCell>
<TableCell>
{email ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<DefaultProxyAdminTag userId={email} />
</span>
</TooltipTrigger>
<TooltipContent>{email}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
"—"
)}
</TableCell>
<TableCell>
{k.created_at
? new Date(k.created_at).toLocaleDateString()
: "—"}
</TableCell>
<TableCell>
{k.last_active
? new Date(k.last_active).toLocaleDateString()
: "Never"}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
);
}

View file

@ -1,8 +1,7 @@
import React from "react";
import { Input, Button } from "antd";
import { ArrowUpOutlined } from "@ant-design/icons";
const { TextArea } = Input;
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ArrowUp } from "lucide-react";
interface MessageInputProps {
value: string;
@ -13,8 +12,16 @@ interface MessageInputProps {
uploadComponent?: React.ReactNode;
}
export function MessageInput({ value, onChange, onSend, disabled, hasAttachment, uploadComponent }: MessageInputProps) {
const canSend = !disabled && (value.trim().length > 0 || Boolean(hasAttachment));
export function MessageInput({
value,
onChange,
onSend,
disabled,
hasAttachment,
uploadComponent,
}: MessageInputProps) {
const canSend =
!disabled && (value.trim().length > 0 || Boolean(hasAttachment));
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
@ -27,27 +34,27 @@ export function MessageInput({ value, onChange, onSend, disabled, hasAttachment,
return (
<div className="flex items-center gap-2">
<div className="flex items-center flex-1 bg-white border border-gray-300 rounded-xl px-3 py-1 min-h-[44px]">
{uploadComponent && <div className="flex-shrink-0 mr-2">{uploadComponent}</div>}
<TextArea
<div className="flex items-center flex-1 bg-background border border-border rounded-xl px-3 py-1 min-h-[44px]">
{uploadComponent && (
<div className="flex-shrink-0 mr-2">{uploadComponent}</div>
)}
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message... (Shift+Enter for new line)"
disabled={disabled}
className="flex-1"
autoSize={{ minRows: 1, maxRows: 4 }}
style={{
resize: "none",
border: "none",
boxShadow: "none",
background: "transparent",
padding: "4px 0",
fontSize: "14px",
lineHeight: "20px",
}}
rows={1}
className="flex-1 resize-none border-0 shadow-none bg-transparent p-0 py-1 text-sm leading-5 min-h-[20px]"
/>
<Button onClick={onSend} disabled={!canSend} icon={<ArrowUpOutlined />} shape="circle" />
<Button
onClick={onSend}
disabled={!canSend}
size="icon"
className="rounded-full h-8 w-8"
>
<ArrowUp className="h-4 w-4" />
</Button>
</div>
</div>
);

View file

@ -1,9 +1,11 @@
/**
* Unified selector component that handles both model and agent selection
* based on the current endpoint configuration.
*/
import { Select, Spin } from "antd";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2 } from "lucide-react";
import { SelectorOption, EndpointConfig } from "../endpoint_config";
interface UnifiedSelectorProps {
@ -22,27 +24,33 @@ export function UnifiedSelector({
onChange,
}: UnifiedSelectorProps) {
return (
<Select
value={value || undefined}
placeholder={loading ? `Loading ${config.selectorLabel.toLowerCase()}s...` : config.selectorPlaceholder}
onChange={onChange}
loading={loading}
showSearch
filterOption={(input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}
options={options}
className="w-48 md:w-64 lg:w-72"
notFoundContent={
loading ? (
<Select value={value || undefined} onValueChange={onChange}>
<SelectTrigger className="w-48 md:w-64 lg:w-72">
<SelectValue
placeholder={
loading
? `Loading ${config.selectorLabel.toLowerCase()}s...`
: config.selectorPlaceholder
}
/>
</SelectTrigger>
<SelectContent>
{loading ? (
<div className="flex items-center justify-center py-2">
<Spin size="small" />
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : options.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No {config.selectorLabel.toLowerCase()}s available
</div>
) : (
`No ${config.selectorLabel.toLowerCase()}s available`
)
}
/>
options.map((opt) => (
<SelectItem key={opt.value as string} value={opt.value as string}>
{opt.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
}