feat(den-web): polish llm provider management ui (#1401)

* feat(den-web): refine llm provider type details

* style(den-web): tighten llm provider detail labels

* feat(den-web): streamline provider access setup

* style(den-web): update custom provider badge icon
This commit is contained in:
terrikramer
2026-04-08 12:58:03 -07:00
committed by GitHub
parent e1ccd5e976
commit 319ac3ee79
3 changed files with 249 additions and 136 deletions

View File

@@ -8,6 +8,8 @@ export type DenSelectableRowProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>
description?: ReactNode;
selected?: boolean;
aside?: ReactNode;
leading?: ReactNode;
descriptionBelow?: boolean;
};
export function DenSelectableRow({
@@ -16,6 +18,8 @@ export function DenSelectableRow({
selected = false,
disabled = false,
aside,
leading,
descriptionBelow = false,
className,
...rest
}: DenSelectableRowProps) {
@@ -37,8 +41,10 @@ export function DenSelectableRow({
.filter(Boolean)
.join(" ")}
>
{leading ? <div className="shrink-0">{leading}</div> : null}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-2">
<div className={descriptionBelow ? "grid min-w-0 gap-0.5" : "flex min-w-0 items-baseline gap-2"}>
<p className="truncate text-[15px] font-medium leading-[1.15] tracking-[-0.02em] text-gray-950">{title}</p>
{description ? <p className="truncate text-[12px] leading-[1.15] text-gray-500">{description}</p> : null}
</div>

View File

@@ -3,7 +3,14 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, CheckCircle2, Circle, Cpu, Search } from "lucide-react";
import {
ArrowLeft,
CodeXml,
Cpu,
Search,
User,
Users,
} from "lucide-react";
import { DenButton } from "../../../../_components/ui/button";
import { DenCombobox } from "../../../../_components/ui/combobox";
import { DenInput } from "../../../../_components/ui/input";
@@ -34,7 +41,7 @@ import {
const SOURCE_TABS = [
{ value: "models_dev" as const, label: "Catalog provider", icon: Cpu },
{ value: "custom" as const, label: "Custom provider", icon: Cpu },
{ value: "custom" as const, label: "Custom provider", icon: CodeXml },
];
function getLockMemberId(
@@ -62,6 +69,8 @@ export function LlmProviderEditorScreen({
[llmProviderId, llmProviders],
);
const [source, setSource] = useState<DenLlmProviderSource>("models_dev");
const [accessTab, setAccessTab] = useState<"teams" | "people">("teams");
const [accessQuery, setAccessQuery] = useState("");
const [catalogProviders, setCatalogProviders] = useState<
DenModelsDevProviderSummary[]
>([]);
@@ -209,6 +218,32 @@ export function LlmProviderEditorScreen({
);
}, [catalogDetail?.models, modelQuery]);
const filteredTeams = useMemo(() => {
const teams = orgContext?.teams ?? [];
const normalizedQuery = accessQuery.trim().toLowerCase();
if (!normalizedQuery) {
return teams;
}
return teams.filter((team) =>
team.name.toLowerCase().includes(normalizedQuery),
);
}, [accessQuery, orgContext?.teams]);
const filteredMembers = useMemo(() => {
const members = orgContext?.members ?? [];
const normalizedQuery = accessQuery.trim().toLowerCase();
if (!normalizedQuery) {
return members;
}
return members.filter(
(member) =>
member.user.name.toLowerCase().includes(normalizedQuery) ||
member.user.email.toLowerCase().includes(normalizedQuery),
);
}, [accessQuery, orgContext?.members]);
const catalogProviderOptions = useMemo(
() =>
catalogProviders.map((catalogProvider) => ({
@@ -451,38 +486,58 @@ export function LlmProviderEditorScreen({
{catalogDetail ? (
<div className="rounded-[28px] bg-gray-50 p-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-[12px] font-semibold uppercase tracking-[0.14em] text-gray-400">
<p className="text-[12px] font-semibold uppercase text-gray-400">
NPM package
</p>
<p className="mt-2 text-[15px] font-medium text-gray-900">
{providerNpm ?? "Not set"}
<p className="mt-2">
<span className="inline-flex max-w-full rounded-md bg-white px-3 py-1.5 font-mono text-[11px] leading-5 text-gray-700 ring-1 ring-inset ring-gray-200">
{providerNpm ?? "Not set"}
</span>
</p>
</div>
<div>
<p className="text-[12px] font-semibold uppercase tracking-[0.14em] text-gray-400">
<p className="text-[12px] font-semibold uppercase text-gray-400">
API base
</p>
<p className="mt-2 break-all text-[15px] font-medium text-gray-900">
{providerApiBase ?? "Not set"}
<p className="mt-2">
<span className="inline-flex max-w-full break-all rounded-md bg-white px-3 py-1.5 font-mono text-[11px] leading-5 text-gray-700 ring-1 ring-inset ring-gray-200">
{providerApiBase ?? "Not set"}
</span>
</p>
</div>
<div>
<p className="text-[12px] font-semibold uppercase tracking-[0.14em] text-gray-400">
<p className="text-[12px] font-semibold uppercase text-gray-400">
Env keys
</p>
<p className="mt-2 text-[15px] font-medium text-gray-900">
{providerEnv.join(", ") ||
"None listed"}
</p>
{providerEnv.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{providerEnv.map((envName) => (
<span
key={envName}
className="inline-flex max-w-full break-all rounded-md bg-white px-3 py-1.5 font-mono text-[11px] leading-5 text-gray-700 ring-1 ring-inset ring-gray-200"
>
{envName}
</span>
))}
</div>
) : (
<p className="mt-2">
<span className="inline-flex max-w-full rounded-md bg-white px-3 py-1.5 font-mono text-[11px] leading-5 text-gray-700 ring-1 ring-inset ring-gray-200">
None listed
</span>
</p>
)}
</div>
<div>
<p className="text-[12px] font-semibold uppercase tracking-[0.14em] text-gray-400">
<p className="text-[12px] font-semibold uppercase text-gray-400">
Docs
</p>
<p className="mt-2 text-[15px] font-medium text-gray-900">
{providerDoc ?? "Not set"}
<p className="mt-2">
<span className="inline-flex max-w-full break-all rounded-md bg-white px-3 py-1.5 font-mono text-[11px] leading-5 text-gray-700 ring-1 ring-inset ring-gray-200">
{providerDoc ?? "Not set"}
</span>
</p>
</div>
</div>
@@ -545,7 +600,7 @@ export function LlmProviderEditorScreen({
{source === "models_dev" ? (
<section className="mb-8 rounded-[36px] border border-gray-200 bg-white p-8 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.24)]">
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<div>
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-[24px] font-semibold tracking-[-0.05em] text-gray-950">
@@ -566,21 +621,22 @@ export function LlmProviderEditorScreen({
</p>
</div>
<DenInput
type="search"
icon={Search}
value={modelQuery}
onChange={(event) =>
setModelQuery(event.target.value)
}
placeholder="Search models..."
className="lg:w-[360px]"
/>
<div className="mt-6">
<DenInput
type="search"
icon={Search}
value={modelQuery}
onChange={(event) =>
setModelQuery(event.target.value)
}
placeholder="Search models..."
/>
</div>
</div>
{catalogDetail ? (
filteredModels.length ? (
<div>
<div className="mt-4">
<div className="overflow-hidden rounded-[16px] border border-gray-200 bg-white divide-y divide-gray-200">
{filteredModels.map((model) => {
const selected =
@@ -614,7 +670,7 @@ export function LlmProviderEditorScreen({
</div>
</div>
) : (
<div className="rounded-[24px] border border-dashed border-gray-200 bg-gray-50 px-5 py-6 text-[15px] text-gray-500">
<div className="mt-4 rounded-[24px] border border-dashed border-gray-200 bg-gray-50 px-5 py-6 text-[15px] text-gray-500">
No models match{" "}
<span className="font-medium text-gray-700">
&quot;{modelQuery}&quot;
@@ -623,118 +679,164 @@ export function LlmProviderEditorScreen({
</div>
)
) : (
<div className="rounded-[24px] border border-dashed border-gray-200 bg-gray-50 px-5 py-6 text-[15px] text-gray-500">
<div className="mt-4 rounded-[24px] border border-dashed border-gray-200 bg-gray-50 px-5 py-6 text-[15px] text-gray-500">
Select a provider to browse its models.
</div>
)}
</section>
) : null}
<section className="mb-8 rounded-[36px] border border-gray-200 bg-white p-8 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.24)]">
<h2 className="text-[24px] font-semibold tracking-[-0.05em] text-gray-950">
People access
</h2>
<div className="mt-8 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{orgContext?.members.map((member) => {
const selected = selectedMemberIds.includes(member.id);
const locked = lockedMemberId === member.id;
return (
<button
key={member.id}
type="button"
disabled={locked}
onClick={() =>
setSelectedMemberIds((current) =>
current.includes(member.id)
? current.filter(
(entry) =>
entry !== member.id,
)
: [...current, member.id],
)
}
className={`flex min-h-[88px] items-center gap-4 rounded-[24px] border px-5 py-4 text-left transition ${selected ? "border-[#0f172a] bg-[#0f172a] text-white" : "border-gray-200 bg-white text-gray-700 hover:border-gray-300"} ${locked ? "cursor-default" : "cursor-pointer"}`}
>
{selected ? (
<CheckCircle2 className="h-7 w-7 shrink-0" />
) : (
<Circle className="h-7 w-7 shrink-0 text-gray-300" />
)}
<div>
<p className="text-[16px] font-medium tracking-[-0.03em]">
{member.user.name}
</p>
<p
className={`mt-1 text-[13px] ${selected ? "text-white/70" : "text-gray-400"}`}
>
{member.user.email}
</p>
{locked ? (
<p
className={`mt-1 text-[12px] ${selected ? "text-white/60" : "text-gray-400"}`}
>
Creator access is locked
</p>
) : null}
</div>
</button>
);
}) ?? null}
</div>
</section>
<section className="rounded-[36px] border border-gray-200 bg-white p-8 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.24)]">
<h2 className="text-[24px] font-semibold tracking-[-0.05em] text-gray-950">
Team access
</h2>
<div>
<h2 className="text-[24px] font-semibold tracking-[-0.05em] text-gray-950">
Configure access
</h2>
<p className="mt-2 text-[15px] text-gray-500">
Select which teams and people can use this provider.
</p>
</div>
{orgContext?.teams.length ? (
<div className="mt-8 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{orgContext.teams.map((team) => {
const selected = selectedTeamIds.includes(team.id);
return (
<button
key={team.id}
type="button"
onClick={() =>
setSelectedTeamIds((current) =>
current.includes(team.id)
? current.filter(
(entry) =>
entry !== team.id,
)
: [...current, team.id],
)
}
className={`flex min-h-[88px] items-center gap-4 rounded-[24px] border px-5 py-4 text-left transition ${selected ? "border-[#0f172a] bg-[#0f172a] text-white" : "border-gray-200 bg-white text-gray-700 hover:border-gray-300"}`}
>
{selected ? (
<CheckCircle2 className="h-7 w-7 shrink-0" />
) : (
<Circle className="h-7 w-7 shrink-0 text-gray-300" />
)}
<div>
<p className="text-[16px] font-medium tracking-[-0.03em]">
{team.name}
</p>
<p
className={`mt-1 text-[13px] ${selected ? "text-white/70" : "text-gray-400"}`}
>
{team.memberIds.length}{" "}
{team.memberIds.length === 1
? "member"
: "members"}
</p>
</div>
</button>
);
})}
</div>
<div className="mt-8 grid w-80 grid-cols-2 rounded-xl bg-gray-200 p-1 text-[13px] font-medium text-gray-500">
<button
type="button"
onClick={() => {
setAccessTab("teams");
setAccessQuery("");
}}
className={`inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 transition ${accessTab === "teams" ? "bg-white text-gray-900 shadow-sm" : "hover:text-gray-700"}`}
>
<Users className="h-4 w-4" />
{`Teams (${selectedTeamIds.length})`}
</button>
<button
type="button"
onClick={() => {
setAccessTab("people");
setAccessQuery("");
}}
className={`inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 transition ${accessTab === "people" ? "bg-white text-gray-900 shadow-sm" : "hover:text-gray-700"}`}
>
<User className="h-4 w-4" />
{`People (${selectedMemberIds.length})`}
</button>
</div>
<div className="mt-6">
<DenInput
type="search"
icon={Search}
value={accessQuery}
onChange={(event) => setAccessQuery(event.target.value)}
placeholder={
accessTab === "teams"
? "Search teams..."
: "Search people..."
}
/>
</div>
{accessTab === "teams" ? (
orgContext?.teams.length ? (
filteredTeams.length ? (
<div className="mt-4 overflow-hidden rounded-[16px] border border-gray-200 bg-white divide-y divide-gray-200">
{filteredTeams.map((team) => {
const selected = selectedTeamIds.includes(team.id);
return (
<DenSelectableRow
key={team.id}
selected={selected}
leading={
<Users className="h-4 w-4 text-gray-400" />
}
title={team.name}
description={`${team.memberIds.length} ${team.memberIds.length === 1 ? "member" : "members"}`}
onClick={() =>
setSelectedTeamIds((current) =>
current.includes(team.id)
? current.filter(
(entry) =>
entry !== team.id,
)
: [...current, team.id],
)
}
/>
);
})}
</div>
) : (
<div className="mt-4 rounded-[24px] border border-dashed border-gray-200 bg-gray-50 px-5 py-6 text-[15px] text-gray-500">
No teams match{" "}
<span className="font-medium text-gray-700">
&quot;{accessQuery}&quot;
</span>
.
</div>
)
) : (
<div className="mt-4 rounded-[24px] border border-dashed border-gray-200 bg-gray-50 px-5 py-6 text-[15px] text-gray-500">
Create teams from the Members page before assigning team
access.
</div>
)
) : orgContext?.members.length ? (
filteredMembers.length ? (
<div className="mt-4 overflow-hidden rounded-[16px] border border-gray-200 bg-white divide-y divide-gray-200">
{filteredMembers.map((member) => {
const selected = selectedMemberIds.includes(
member.id,
);
const locked = lockedMemberId === member.id;
return (
<DenSelectableRow
key={member.id}
disabled={locked}
selected={selected}
leading={
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#0f172a] text-[11px] font-semibold uppercase text-white">
{member.user.name
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2)}
</div>
}
descriptionBelow
title={member.user.name}
description={member.user.email}
aside={
locked ? (
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-[11px] font-medium text-gray-500">
Locked
</span>
) : undefined
}
onClick={() =>
setSelectedMemberIds((current) =>
current.includes(member.id)
? current.filter(
(entry) =>
entry !== member.id,
)
: [...current, member.id],
)
}
/>
);
})}
</div>
) : (
<div className="mt-4 rounded-[24px] border border-dashed border-gray-200 bg-gray-50 px-5 py-6 text-[15px] text-gray-500">
No people match{" "}
<span className="font-medium text-gray-700">
&quot;{accessQuery}&quot;
</span>
.
</div>
)
) : (
<div className="mt-8 rounded-[24px] border border-dashed border-gray-200 bg-gray-50 px-5 py-6 text-[15px] text-gray-500">
Create teams from the Members page before assigning team
access.
<div className="mt-4 rounded-[24px] border border-dashed border-gray-200 bg-gray-50 px-5 py-6 text-[15px] text-gray-500">
No people are available to assign yet.
</div>
)}
</section>

View File

@@ -2,7 +2,7 @@
import Link from "next/link";
import { useMemo, useState } from "react";
import { Cpu, KeyRound, Plus, Search } from "lucide-react";
import { CodeXml, Cpu, KeyRound, Plus, Search } from "lucide-react";
import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template";
import { buttonVariants } from "../../../../_components/ui/button";
import { DenInput } from "../../../../_components/ui/input";
@@ -22,6 +22,10 @@ function getProviderSourceLabel(source: "models_dev" | "custom") {
return source === "custom" ? "Custom" : "Catalog";
}
function getProviderSourceIcon(source: "models_dev" | "custom") {
return source === "custom" ? CodeXml : Cpu;
}
export function LlmProvidersScreen() {
const { orgId, orgSlug } = useOrgDashboard();
const { llmProviders, busy, error } = useOrgLlmProviders(orgId);
@@ -93,6 +97,7 @@ export function LlmProvidersScreen() {
) : (
<div className="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
{filteredProviders.map((provider) => {
const SourceIcon = getProviderSourceIcon(provider.source);
const envNames = getProviderEnvNames(provider.providerConfig);
const memberAccessCount = provider.access.members.length;
const teamAccessCount = provider.access.teams.length;
@@ -105,7 +110,7 @@ export function LlmProvidersScreen() {
<div className="flex items-start justify-between gap-4">
<div>
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-100 bg-emerald-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-emerald-700">
<Cpu className="h-3.5 w-3.5" />
<SourceIcon className="h-3.5 w-3.5" />
{getProviderSourceLabel(provider.source)}
</div>
<h2 className="mt-4 text-[22px] font-semibold tracking-[-0.05em] text-gray-950">{provider.name}</h2>