feat(ui): migrate tool-policies (PolicySelect) to shadcn

- PolicySelect.tsx: antd Select \u2192 shadcn Select + SelectTrigger/
  Content/Item. Preserves categorical amber/emerald/red palette for the
  untrusted/trusted/blocked policy badges (these are non-semantic values
  by design). PolicySelect.tsx added to .eslintrc.json raw-colors override
  list. Deviation recorded in DEVIATIONS.md.
- PolicySelect.test.tsx: replace `.ant-select` class assertion with
  semantic `toBeDisabled()` against getByRole('combobox'), agnostic of
  underlying Select primitive.

Gates: TS \u2713 | Lint \u2713 | Vitest 12/12 \u2713 | Build \u2713.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-23 07:11:23 +00:00
parent 01cc55886e
commit b0ef2c70d4
No known key found for this signature in database
5 changed files with 100 additions and 55 deletions

View file

@ -22,7 +22,8 @@
"src/components/ui/**/*.{ts,tsx}",
"tailwind.config.ts",
"src/components/molecules/notifications_manager.tsx",
"src/components/molecules/message_manager.tsx"
"src/components/molecules/message_manager.tsx",
"src/components/ToolPolicies/PolicySelect.tsx"
],
"rules": {
"litellm-ui/no-banned-ui-imports": "off",

View file

@ -43,3 +43,12 @@ Layer abbreviations: **TS** (tsc --noEmit), **Lint** (eslint), **Vitest**
- cycle 1: TS ✓ | Lint ✓ (pre-existing unused-imports in test excluded) | Vitest ✓ (6/6) | Parity ⏭ | Snap ⏭
- Final status: **done**
## 6. Tool Policies (tool-policies)
- Cycles used: 1 / 7
- Layer outcomes per cycle:
- cycle 1: TS ✓ | Lint ✓ (PolicySelect.tsx added to raw-colors override list) | Vitest ✓ (12/12) | Parity ⏭ | Snap ⏭
- Final status: **done**
- Scope: PolicySelect.tsx only (the single banned-import file under ToolPolicies/). The ToolPoliciesView.tsx top-level wrapper has no antd imports already. Top-level ToolPolicies.tsx legacy wrapper is not in scope.
- Section decision: keep amber/emerald/red categorical colors for policy badges (not theme-semantic). Documented in DEVIATIONS.md.

View file

@ -45,6 +45,21 @@ Format per entry:
---
## Section 6 (Tool Policies) — categorical colors (amber/emerald/red) on policy badges
- **Type:** `blueprint deviation`
- **Blueprint rule deviated from:** §1 "semantic Tailwind tokens only".
- **What the agent did instead:** `PolicySelect.tsx` uses raw palette
classes (`bg-amber-100 text-amber-800 border-amber-300`,
`bg-emerald-100 ...`, `bg-red-100 ...`) and is added to the
`litellm-ui/no-raw-tailwind-colors` override list in `.eslintrc.json`.
- **Why:** The policy badges encode a **categorical** (untrusted /
trusted / blocked), not a theme state. Semantic tokens (`primary`,
`muted`, `destructive`, ...) don't have three distinct hues suitable
for three categorical values. A phase-2 task could introduce semantic
policy tokens (`--policy-untrusted`, `--policy-trusted`,
`--policy-blocked`) backed by CSS variables; out of scope for phase 1.
## Section 1 (Access Groups) — custom MultiSelect shim in lieu of antd Select mode=multiple
- **Type:** `blueprint deviation`

View file

@ -55,8 +55,9 @@ describe("PolicySelect", () => {
onChange={vi.fn()}
/>
);
expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false");
expect(screen.getByRole("combobox").closest(".ant-select")).toHaveClass("ant-select-disabled");
// shadcn Select's combobox trigger exposes `data-disabled` when disabled.
const combobox = screen.getByRole("combobox");
expect(combobox).toBeDisabled();
});
it("should not be disabled when saving is false", () => {
@ -68,7 +69,7 @@ describe("PolicySelect", () => {
onChange={vi.fn()}
/>
);
expect(screen.getByRole("combobox").closest(".ant-select")).not.toHaveClass("ant-select-disabled");
expect(screen.getByRole("combobox")).not.toBeDisabled();
});
});

View file

@ -1,17 +1,39 @@
"use client";
import React from "react";
import { Select } from "antd";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
export const INPUT_POLICY_OPTIONS = [
{ value: "untrusted", label: "untrusted", color: "#92400e", bg: "#fef3c7", border: "#fcd34d" },
{ value: "trusted", label: "trusted", color: "#065f46", bg: "#d1fae5", border: "#6ee7b7" },
{ value: "blocked", label: "blocked", color: "#991b1b", bg: "#fee2e2", border: "#fca5a5" },
{
value: "untrusted",
label: "untrusted",
className: "bg-amber-100 text-amber-800 border-amber-300",
dotClass: "bg-amber-800",
},
{
value: "trusted",
label: "trusted",
className: "bg-emerald-100 text-emerald-800 border-emerald-300",
dotClass: "bg-emerald-800",
},
{
value: "blocked",
label: "blocked",
className: "bg-red-100 text-red-800 border-red-300",
dotClass: "bg-red-800",
},
] as const;
export const OUTPUT_POLICY_OPTIONS = [
{ value: "untrusted", label: "untrusted", color: "#92400e", bg: "#fef3c7", border: "#fcd34d" },
{ value: "trusted", label: "trusted", color: "#065f46", bg: "#d1fae5", border: "#6ee7b7" },
INPUT_POLICY_OPTIONS[0],
INPUT_POLICY_OPTIONS[1],
] as const;
export const POLICY_OPTIONS = INPUT_POLICY_OPTIONS;
@ -30,6 +52,12 @@ export interface PolicySelectProps {
stopPropagation?: boolean;
}
// Color pill around the shadcn Select trigger. This is the one place in
// phase 1 where we reach for named Tailwind colors — the policy-badge
// palette (amber/emerald/red) is purposely non-semantic because it
// encodes a categorical (untrusted / trusted / blocked) not a theme state.
// Recorded in DEVIATIONS.md if we decide to force semantic-token
// conformance later.
export const PolicySelect: React.FC<PolicySelectProps> = ({
value,
toolName,
@ -40,53 +68,44 @@ export const PolicySelect: React.FC<PolicySelectProps> = ({
minWidth = 110,
stopPropagation = true,
}) => {
const options = policyType === "output" ? OUTPUT_POLICY_OPTIONS : INPUT_POLICY_OPTIONS;
const options =
policyType === "output" ? OUTPUT_POLICY_OPTIONS : INPUT_POLICY_OPTIONS;
const style = policyStyle(value);
const triggerHeight = size === "small" ? "h-7" : "h-8";
const fontSize = size === "small" ? "text-[11px]" : "text-xs";
return (
<Select
size={size}
value={value}
disabled={saving}
loading={saving}
onChange={(v) => onChange(toolName, v)}
<div
onClick={(e) => stopPropagation && e.stopPropagation()}
style={{
minWidth,
fontWeight: 500,
backgroundColor: style.bg,
borderColor: style.border,
color: style.color,
borderRadius: 999,
fontSize: size === "small" ? 11 : 12,
}}
popupMatchSelectWidth={false}
options={options.map((o) => ({
value: o.value,
label: (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 12,
fontWeight: 500,
color: o.color,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
backgroundColor: o.color,
display: "inline-block",
flexShrink: 0,
}}
/>
{o.label}
</span>
),
}))}
/>
style={{ minWidth }}
>
<Select
value={value}
disabled={saving}
onValueChange={(v) => onChange(toolName, v)}
>
<SelectTrigger
className={cn(
"font-medium rounded-full border",
style.className,
triggerHeight,
fontSize,
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent className="w-auto">
{options.map((o) => (
<SelectItem key={o.value} value={o.value}>
<span className={cn("inline-flex items-center gap-1.5", fontSize)}>
<span
className={cn("w-2 h-2 rounded-full inline-block", o.dotClass)}
/>
{o.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};