feat(ui): migrate guardrails subcomponents (GuardrailSelector, guardrail_garden_card) to shadcn

- guardrail_garden_card: @ant-design/icons CheckCircleFilled \u2192 lucide
  CheckCircle2. Replace inline-style hover state with Tailwind hover:
  classes. Replace raw color tokens (#e5e7eb / #6b7280 / #16a34a) with
  semantic tokens; the F1 success badge keeps emerald palette
  (categorical \u2014 success-only color).
- GuardrailSelector: antd Select mode=multiple \u2192 chip-style multi-select
  via shadcn Popover/Input/Badge (mirrors VectorStoreSelector pattern).
  Search-filter the suggestions and surface the disabled premium-feature
  message inline.

Gates: TS \u2713 | Vitest 7/7 (guardrail_garden_card) \u2713 | Build \u2713.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-23 08:16:34 +00:00
parent 8ae6fed101
commit 6c315e702f
No known key found for this signature in database
2 changed files with 137 additions and 87 deletions

View file

@ -1,5 +1,13 @@
import React, { useEffect, useState } from "react";
import { Select } from "antd";
import React, { useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Guardrail } from "./types";
import { getGuardrailsList } from "../networking";
@ -11,60 +19,123 @@ interface GuardrailSelectorProps {
disabled?: boolean;
}
const GuardrailSelector: React.FC<GuardrailSelectorProps> = ({ onChange, value, className, accessToken, disabled }) => {
/**
* Multi-select for guardrails. Mirrors the VectorStoreSelector pattern
* (chip-style trigger + popover suggestion list with search) since shadcn
* lacks a native multi-select primitive.
*/
const GuardrailSelector: React.FC<GuardrailSelectorProps> = ({
onChange,
value,
className,
accessToken,
disabled,
}) => {
const [guardrails, setGuardrails] = useState<Guardrail[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const selected = useMemo(() => value ?? [], [value]);
useEffect(() => {
const fetchGuardrails = async () => {
if (!accessToken) return;
setLoading(true);
try {
const response = await getGuardrailsList(accessToken);
console.log("Guardrails response:", response);
if (response.guardrails) {
console.log("Guardrails data:", response.guardrails);
setGuardrails(response.guardrails);
}
if (response.guardrails) setGuardrails(response.guardrails);
} catch (error) {
console.error("Error fetching guardrails:", error);
} finally {
setLoading(false);
}
};
fetchGuardrails();
}, [accessToken]);
const handleGuardrailChange = (selectedValues: string[]) => {
console.log("Selected guardrails:", selectedValues);
onChange(selectedValues);
};
const remaining = useMemo(
() =>
guardrails
.filter((g) => g.guardrail_name && !selected.includes(g.guardrail_name))
.filter((g) =>
query
? (g.guardrail_name ?? "")
.toLowerCase()
.includes(query.toLowerCase())
: true,
),
[guardrails, selected, query],
);
return (
<div>
<Select
mode="multiple"
disabled={disabled}
placeholder={disabled ? "Setting guardrails is a premium feature." : "Select guardrails"}
onChange={handleGuardrailChange}
value={value}
loading={loading}
className={className}
allowClear
options={guardrails.map((guardrail) => {
console.log("Mapping guardrail:", guardrail);
return {
label: `${guardrail.guardrail_name}`,
value: guardrail.guardrail_name,
};
})}
optionFilterProp="label"
showSearch
style={{ width: "100%" }}
/>
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
className={cn(
"min-h-9 w-full flex flex-wrap items-center gap-1 rounded-md border border-input bg-background px-2 py-1 text-sm text-left disabled:opacity-50",
className,
)}
>
{selected.length === 0 ? (
<span className="text-muted-foreground px-1">
{disabled
? "Setting guardrails is a premium feature."
: "Select guardrails"}
</span>
) : (
selected.map((name) => (
<Badge key={name} variant="secondary" className="gap-1">
{name}
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onChange(selected.filter((s) => s !== name));
}}
className="inline-flex items-center"
aria-label={`Remove ${name}`}
>
<X size={12} />
</span>
</Badge>
))
)}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-2"
>
<Input
autoFocus
placeholder="Search guardrails…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 mb-2"
/>
<div className="max-h-60 overflow-y-auto">
{remaining.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No matches
</div>
) : (
remaining.map((g) => {
const name = g.guardrail_name as string;
return (
<button
key={name}
type="button"
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent"
onClick={() => onChange([...selected, name])}
>
{name}
</button>
);
})
)}
</div>
</PopoverContent>
</Popover>
);
};

View file

@ -1,27 +1,17 @@
import React, { useState } from "react";
import { CheckCircleFilled } from "@ant-design/icons";
import { CheckCircle2 } from "lucide-react";
import { GuardrailCardInfo } from "./guardrail_garden_data";
import { cn } from "@/lib/utils";
const LogoWithFallback: React.FC<{ src: string; name: string }> = ({ src, name }) => {
const LogoWithFallback: React.FC<{ src: string; name: string }> = ({
src,
name,
}) => {
const [hasError, setHasError] = useState(false);
if (hasError || !src) {
return (
<div
style={{
width: 28,
height: 28,
borderRadius: 6,
backgroundColor: "#e5e7eb",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 13,
fontWeight: 600,
color: "#6b7280",
flexShrink: 0,
}}
>
<div className="w-7 h-7 rounded-md bg-muted flex items-center justify-center text-sm font-semibold text-muted-foreground shrink-0">
{name?.charAt(0) || "?"}
</div>
);
@ -31,52 +21,41 @@ const LogoWithFallback: React.FC<{ src: string; name: string }> = ({ src, name }
<img
src={src}
alt=""
style={{ width: 28, height: 28, borderRadius: 6, objectFit: "contain", flexShrink: 0 }}
className="w-7 h-7 rounded-md object-contain shrink-0"
onError={() => setHasError(true)}
/>
);
};
const GuardrailCard: React.FC<{ card: GuardrailCardInfo; onClick: () => void }> = ({ card, onClick }) => {
const [hovered, setHovered] = useState(false);
const GuardrailCard: React.FC<{
card: GuardrailCardInfo;
onClick: () => void;
}> = ({ card, onClick }) => {
return (
<div
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
borderRadius: 12,
border: hovered ? "1px solid #93c5fd" : "1px solid #e5e7eb",
backgroundColor: "#ffffff",
padding: "20px 20px 16px 20px",
cursor: "pointer",
transition: "border-color 0.15s, box-shadow 0.15s",
display: "flex",
flexDirection: "column",
minHeight: 170,
boxShadow: hovered ? "0 1px 6px rgba(59,130,246,0.08)" : "none",
}}
className={cn(
"rounded-xl border border-border bg-background px-5 pt-5 pb-4",
"cursor-pointer transition-colors transition-shadow",
"hover:border-primary/50 hover:shadow-sm",
"flex flex-col min-h-[170px]",
)}
>
{/* Icon + Name row */}
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 10 }}>
<div className="flex items-center gap-2.5 mb-2.5">
<LogoWithFallback src={card.logo} name={card.name} />
<span style={{ fontSize: 14, fontWeight: 600, color: "#111827", lineHeight: 1.3 }}>{card.name}</span>
<span className="text-sm font-semibold text-foreground leading-tight">
{card.name}
</span>
</div>
{/* Description */}
<p
className="line-clamp-3"
style={{ fontSize: 12, color: "#6b7280", lineHeight: 1.6, margin: 0, flex: 1 }}
>
<p className="line-clamp-3 text-xs text-muted-foreground leading-relaxed m-0 flex-1">
{card.description}
</p>
{/* Eval badge */}
{card.eval && (
<div style={{ marginTop: 10, display: "flex", alignItems: "center", gap: 4 }}>
<CheckCircleFilled style={{ color: "#16a34a", fontSize: 12 }} />
<span style={{ fontSize: 11, color: "#16a34a", fontWeight: 500 }}>
<div className="mt-2.5 flex items-center gap-1">
<CheckCircle2 className="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
<span className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">
F1: {card.eval.f1}% &middot; {card.eval.testCases} test cases
</span>
</div>