This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git


The following commit(s) were added to refs/heads/master by this push:
     new d65dd26  feat(web): mark brand items in dropdowns with their icons
d65dd26 is described below

commit d65dd263c65c9749f9639fd3a60225b3ee778831
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 20:35:02 2026 +0800

    feat(web): mark brand items in dropdowns with their icons
---
 web/src/components/AgentGridCard.tsx             |  1 +
 web/src/components/AgentIcon.tsx                 | 13 +++-
 web/src/components/ProbeCaseList.tsx             |  7 +-
 web/src/components/ProviderCard.tsx              |  4 +-
 web/src/components/ProviderQuota.tsx             |  3 +-
 web/src/components/settings/cloud-sync-panel.tsx |  3 +-
 web/src/components/shared/brand-options.tsx      | 90 ++++++++++++++++++++++++
 web/src/components/shared/simple-select.tsx      |  8 ++-
 web/src/lib/providers.ts                         |  4 --
 web/src/lib/quota.ts                             |  6 ++
 web/src/pages/AgentRecordsPage.tsx               |  3 +-
 web/src/pages/AgentSessionsPage.tsx              |  6 +-
 web/src/pages/ProviderEditPage.tsx               | 10 +--
 web/src/pages/ProviderListPage.tsx               | 12 ++--
 web/src/pages/UsagePage.tsx                      |  3 +-
 15 files changed, 140 insertions(+), 33 deletions(-)

diff --git a/web/src/components/AgentGridCard.tsx 
b/web/src/components/AgentGridCard.tsx
index de9bcc5..757008a 100644
--- a/web/src/components/AgentGridCard.tsx
+++ b/web/src/components/AgentGridCard.tsx
@@ -286,6 +286,7 @@ export function AgentGridCard({
             </SelectTrigger>
             <SelectContent>
               <SelectItem value={builtinProvider}>
+                <AgentIcon agent={agent.agentId || agent.name} size={16} 
fallback={<Bot className="size-4" />} />
                 {agentBuiltin(agent)}
                 <span className="text-muted-foreground ml-2 text-xs">
                   {i18next.t("agent:Built-in")}
diff --git a/web/src/components/AgentIcon.tsx b/web/src/components/AgentIcon.tsx
index 5134a9b..d9a32bf 100644
--- a/web/src/components/AgentIcon.tsx
+++ b/web/src/components/AgentIcon.tsx
@@ -47,13 +47,22 @@ const agentSites: Record<string, string> = {
   "CodeBuddy Code": "codebuddy.ai",
 };
 
+// Records name an agent by id, and a few ids do not spell out the name above.
+const agentIdSites: Record<string, string> = {
+  codebuddy: "codebuddy.ai",
+  dsh: "deepseek.com",
+  "kimi-code": "kimi.com",
+};
+
 const agentKey = (agent: unknown) =>
   String(agent || "")
     .toLowerCase()
     .replace(/[^a-z0-9]/g, "");
 
-const agentSitesByKey = Object.keys(agentSites).reduce<Record<string, 
string>>((sites, name) => {
-  sites[agentKey(name)] = agentSites[name];
+const agentSitesByKey = [agentSites, agentIdSites].reduce<Record<string, 
string>>((sites, source) => {
+  Object.keys(source).forEach(name => {
+    sites[agentKey(name)] = source[name];
+  });
   return sites;
 }, {});
 
diff --git a/web/src/components/ProbeCaseList.tsx 
b/web/src/components/ProbeCaseList.tsx
index 0c985b8..36c4b98 100644
--- a/web/src/components/ProbeCaseList.tsx
+++ b/web/src/components/ProbeCaseList.tsx
@@ -23,6 +23,7 @@ import {EmptyState} from "@/components/shared/empty-state";
 import {Field, FormDialog} from "@/components/shared/form-dialog";
 import {Loading} from "@/components/shared/loading";
 import {NumberInput} from "@/components/shared/number-input";
+import {protocolOptions} from "@/components/shared/brand-options";
 import {SimpleSelect} from "@/components/shared/simple-select";
 import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
@@ -442,11 +443,7 @@ function CaseDialog({
           <SimpleSelect
             value={draft.protocol}
             onChange={value => set({protocol: value as ProbeCase["protocol"]})}
-            options={[
-              {label: i18next.t("audit:Both APIs"), value: ""},
-              {label: "Anthropic", value: "anthropic"},
-              {label: "OpenAI", value: "openai"},
-            ]}
+            options={[{label: i18next.t("audit:Both APIs"), value: ""}, 
...protocolOptions]}
           />
         </Field>
         <Field label={i18next.t("audit:Only these vendors")} 
hint={i18next.t("audit:Vendors hint")}>
diff --git a/web/src/components/ProviderCard.tsx 
b/web/src/components/ProviderCard.tsx
index 40b6418..8fec4c4 100644
--- a/web/src/components/ProviderCard.tsx
+++ b/web/src/components/ProviderCard.tsx
@@ -13,12 +13,13 @@
 // limitations under the License.
 
 import * as React from "react";
-import {FileCog, RotateCcw} from "lucide-react";
+import {Bot, FileCog, RotateCcw} from "lucide-react";
 import i18next from "i18next";
 
 import * as AgentBackend from "@/backend/AgentBackend";
 import {EnvSnippet} from "@/components/EnvSnippet";
 import {EnvWarningBanner} from "@/components/EnvWarningBanner";
+import {AgentIcon} from "@/components/AgentIcon";
 import {ProviderIcon} from "@/components/ProviderIcon";
 import {ConfirmDialog} from "@/components/shared/confirm-dialog";
 import {Loading} from "@/components/shared/loading";
@@ -260,6 +261,7 @@ export function ProviderCard({
           </SelectTrigger>
           <SelectContent>
             <SelectItem value={builtinProvider}>
+              <AgentIcon agent={agent.agentId || agent.name} size={16} 
fallback={<Bot className="size-4" />} />
               {agentBuiltin(agent)}
               <span className="ml-2 text-xs text-muted-foreground">
                 {i18next.t("agent:Built-in")}
diff --git a/web/src/components/ProviderQuota.tsx 
b/web/src/components/ProviderQuota.tsx
index e4ff7d6..f1756d4 100644
--- a/web/src/components/ProviderQuota.tsx
+++ b/web/src/components/ProviderQuota.tsx
@@ -22,6 +22,7 @@ import {Field} from "@/components/shared/form-dialog";
 import {NumberInput} from "@/components/shared/number-input";
 import {PasswordInput} from "@/components/shared/password-input";
 import {Section} from "@/components/shared/page-header";
+import {vendorOption} from "@/components/shared/brand-options";
 import {SimpleSelect} from "@/components/shared/simple-select";
 import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
@@ -222,7 +223,7 @@ export function ProviderQuotaSection({
                   onChange({...preset.config, headers: 
{...preset.config.headers}});
                 }
               }}
-              options={quotaPresets.map(preset => ({label: preset.label, 
value: preset.key}))}
+              options={quotaPresets.map(preset => vendorOption(preset.key, 
preset.label, preset.icon))}
             />
           </Field>
           <Field label={i18next.t("provider:Balance URL")} htmlFor="quota-url" 
hint={i18next.t("provider:Balance URL hint")}>
diff --git a/web/src/components/settings/cloud-sync-panel.tsx 
b/web/src/components/settings/cloud-sync-panel.tsx
index 32de3a0..ea615a9 100644
--- a/web/src/components/settings/cloud-sync-panel.tsx
+++ b/web/src/components/settings/cloud-sync-panel.tsx
@@ -20,6 +20,7 @@ import * as CloudSyncBackend from 
"@/backend/CloudSyncBackend";
 import * as Setting from "@/Setting";
 import {Field} from "@/components/shared/form-dialog";
 import {PasswordInput} from "@/components/shared/password-input";
+import {storageOption} from "@/components/shared/brand-options";
 import {SimpleSelect} from "@/components/shared/simple-select";
 import {MessageAlert} from "@/components/ui/alert";
 import {Badge} from "@/components/ui/badge";
@@ -216,7 +217,7 @@ export function CloudSyncPanel({onSynced}: {onSynced: () => 
void}) {
                 setKind(value);
                 setTested("");
               }}
-              options={kinds.map(item => ({label: text(item.displayName), 
value: item.name}))}
+              options={kinds.map(item => storageOption(item.name, 
text(item.displayName)))}
             />
           </Field>
 
diff --git a/web/src/components/shared/brand-options.tsx 
b/web/src/components/shared/brand-options.tsx
new file mode 100644
index 0000000..906137e
--- /dev/null
+++ b/web/src/components/shared/brand-options.tsx
@@ -0,0 +1,90 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {Bot, Cloud, Folder, Globe, Settings2} from "lucide-react";
+
+import {AgentIcon} from "@/components/AgentIcon";
+import {ProviderIcon} from "@/components/ProviderIcon";
+import type {SelectOption} from "@/components/shared/simple-select";
+import {providerPresets} from "@/lib/providers";
+
+/** A dropdown item that names a brand is read by its mark first. */
+function brandOption(value: string, text: string, mark: React.ReactNode): 
SelectOption {
+  return {
+    value,
+    text,
+    label: (
+      <span className="flex items-center gap-2 truncate">
+        {mark}
+        {text}
+      </span>
+    ),
+  };
+}
+
+/** An agent, named by the id its records carry or by its own display name. */
+export function agentOption(agent: string, label?: string): SelectOption {
+  return brandOption(
+    agent,
+    label ?? agent,
+    <AgentIcon agent={agent} size={16} fallback={<Bot className="size-4 
opacity-50" />} />,
+  );
+}
+
+/** A vendor, named by the site its mark is served from. */
+export function vendorOption(value: string, label: string, site: string): 
SelectOption {
+  return brandOption(value, label, <ProviderIcon icon={site} size={16} />);
+}
+
+/** The wire format a provider serves, which is a vendor's API either way. */
+export const protocolOptions: SelectOption[] = [
+  vendorOption("openai", "OpenAI", "openai.com"),
+  vendorOption("anthropic", "Anthropic", "anthropic.com"),
+];
+
+/** The Type field of both provider forms: two vendors and everything else. */
+export const providerTypeOptions: SelectOption[] = [
+  ...protocolOptions,
+  brandOption("custom", "Custom", <Settings2 className="size-4 opacity-50" />),
+];
+
+/**
+ * A storage cloud sync writes to. Only S3 is a brand: a folder is a folder, 
and
+ * WebDAV is a protocol nobody draws a logo for, so those two are shown as what
+ * they are.
+ */
+const storageMarks: Record<string, React.ReactNode> = {
+  folder: <Folder className="size-4 opacity-50" />,
+  s3: <ProviderIcon icon="aws.amazon.com" size={16} />,
+  webdav: <Globe className="size-4 opacity-50" />,
+};
+
+export function storageOption(kind: string, label: string): SelectOption {
+  return brandOption(kind, label, storageMarks[kind] ?? <Cloud 
className="size-4 opacity-50" />);
+}
+
+/** The base URLs a type's vendors answer on, each behind its own mark. The
+ *  mark comes from the vendor's own site: an API host often has no icon. */
+export function baseUrlOptions(type: string): SelectOption[] {
+  return providerPresets
+    .filter(preset => preset.type === type)
+    .map(preset =>
+      brandOption(
+        preset.baseUrl,
+        preset.baseUrl,
+        <ProviderIcon icon={preset.website} baseUrl={preset.baseUrl} size={16} 
/>,
+      ),
+    );
+}
diff --git a/web/src/components/shared/simple-select.tsx 
b/web/src/components/shared/simple-select.tsx
index 61319e3..d8b2248 100644
--- a/web/src/components/shared/simple-select.tsx
+++ b/web/src/components/shared/simple-select.tsx
@@ -26,6 +26,8 @@ export interface SelectOption {
   label: React.ReactNode;
   value: string;
   disabled?: boolean;
+  /** What a search matches against, for a label that is a brand mark and a 
name. */
+  text?: string;
 }
 
 // Radix refuses an item whose value is the empty string, but "" is exactly how
@@ -165,7 +167,11 @@ export function SearchSelect({
                 </CommandItem>
               ) : null}
               {items.map(option => (
-                <CommandItem key={option.value} value={String(option.label)} 
onSelect={() => pick(option.value)}>
+                <CommandItem
+                  key={option.value}
+                  value={option.text ?? String(option.label)}
+                  onSelect={() => pick(option.value)}
+                >
                   <Check className={cn("size-4", option.value === value ? 
"opacity-100" : "opacity-0")} />
                   {option.label}
                 </CommandItem>
diff --git a/web/src/lib/providers.ts b/web/src/lib/providers.ts
index 59df16c..5cc96d7 100644
--- a/web/src/lib/providers.ts
+++ b/web/src/lib/providers.ts
@@ -556,10 +556,6 @@ export function presetOfSource(source: ProviderSource) {
 }
 
 /** The base URLs and models offered for a provider type, from the vendors of 
it. */
-export function baseUrlPresets(type: string) {
-  return providerPresets.filter(preset => preset.type === type).map(preset => 
preset.baseUrl);
-}
-
 export function modelPresets(type: string) {
   return providerPresets.filter(preset => preset.type === type).flatMap(preset 
=> preset.models);
 }
diff --git a/web/src/lib/quota.ts b/web/src/lib/quota.ts
index e0a57a4..cbb40a0 100644
--- a/web/src/lib/quota.ts
+++ b/web/src/lib/quota.ts
@@ -48,6 +48,8 @@ export function emptyQuotaConfig(): QuotaConfig {
 export interface QuotaPreset {
   key: string;
   label: string;
+  /** The site, or the image, the project's own mark comes from. */
+  icon: string;
   config: QuotaConfig;
 }
 
@@ -55,6 +57,7 @@ export const quotaPresets: QuotaPreset[] = [
   {
     key: "newapi",
     label: "New API",
+    icon: "newapi.pro",
     config: {
       ...emptyQuotaConfig(),
       url: "/api/user/self",
@@ -68,6 +71,9 @@ export const quotaPresets: QuotaPreset[] = [
   {
     key: "oneapi",
     label: "One API",
+    // One API has no site of its own, and the logo in its repository is
+    // OpenAI's, so it is marked by where the project lives instead.
+    icon: "github.com",
     config: {
       ...emptyQuotaConfig(),
       url: "/api/user/self",
diff --git a/web/src/pages/AgentRecordsPage.tsx 
b/web/src/pages/AgentRecordsPage.tsx
index 4daf118..7ba1d1a 100644
--- a/web/src/pages/AgentRecordsPage.tsx
+++ b/web/src/pages/AgentRecordsPage.tsx
@@ -23,6 +23,7 @@ import {AgentIcon} from "@/components/AgentIcon";
 import {DataTable, type Column} from "@/components/shared/data-table";
 import {CodeBlock, CodeText, DescriptionList, UnauthorizedResult} from 
"@/components/shared/misc";
 import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {agentOption} from "@/components/shared/brand-options";
 import {SimpleSelect} from "@/components/shared/simple-select";
 import {getOutcomeVariant, monitorAgentId} from "@/lib/agents";
 import {MessageAlert} from "@/components/ui/alert";
@@ -292,7 +293,7 @@ export default function AgentRecordsPage({account}: 
{account: Account}) {
           onChange={value => setFilter("agent", value)}
           options={[
             {label: i18next.t("agent:All agents"), value: ALL},
-            ...agentOptions.map(value => ({label: value, value})),
+            ...agentOptions.map(value => agentOption(value)),
           ]}
         />
         <SimpleSelect
diff --git a/web/src/pages/AgentSessionsPage.tsx 
b/web/src/pages/AgentSessionsPage.tsx
index 9fccba7..d7f12c4 100644
--- a/web/src/pages/AgentSessionsPage.tsx
+++ b/web/src/pages/AgentSessionsPage.tsx
@@ -23,6 +23,7 @@ import {AgentIcon} from "@/components/AgentIcon";
 import {DataTable, type Column} from "@/components/shared/data-table";
 import {UnauthorizedResult} from "@/components/shared/misc";
 import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {agentOption} from "@/components/shared/brand-options";
 import {SimpleSelect} from "@/components/shared/simple-select";
 import {StatCard} from "@/components/shared/stat-card";
 import {SimpleTooltip} from "@/components/ui/tooltip";
@@ -350,7 +351,10 @@ export default function AgentSessionsPage({account}: 
{account: Account}) {
           value={agent}
           onChange={setAgent}
           aria-label={i18next.t("agent:Agent")}
-          options={[{label: i18next.t("agent:All agents"), value: ""}, 
...agentOptions]}
+          options={[
+            {label: i18next.t("agent:All agents"), value: ""},
+            ...agentOptions.map(name => agentOption(name)),
+          ]}
         />
       </div>
 
diff --git a/web/src/pages/ProviderEditPage.tsx 
b/web/src/pages/ProviderEditPage.tsx
index 358f7da..3bb8e4e 100644
--- a/web/src/pages/ProviderEditPage.tsx
+++ b/web/src/pages/ProviderEditPage.tsx
@@ -30,6 +30,7 @@ import {CodeText, ResultScreen} from 
"@/components/shared/misc";
 import {NumberInput} from "@/components/shared/number-input";
 import {PageContainer, PageHeader, Section} from 
"@/components/shared/page-header";
 import {PasswordInput} from "@/components/shared/password-input";
+import {baseUrlOptions, providerTypeOptions} from 
"@/components/shared/brand-options";
 import {SearchSelect, SimpleSelect} from "@/components/shared/simple-select";
 import {Button} from "@/components/ui/button";
 import {Input} from "@/components/ui/input";
@@ -37,7 +38,6 @@ import {Textarea} from "@/components/ui/textarea";
 import {
   authClient,
   baseUrlPlaceholder,
-  baseUrlPresets,
   providerProtocol,
   gatewayBaseUrl,
   localShell,
@@ -195,11 +195,7 @@ export default function ProviderEditPage() {
           <SimpleSelect
             value={provider.type}
             onChange={value => setField("type", value)}
-            options={[
-              {label: "OpenAI", value: "openai"},
-              {label: "Anthropic", value: "anthropic"},
-              {label: "Custom", value: "custom"},
-            ]}
+            options={providerTypeOptions}
           />
         </Field>
         <Field
@@ -216,7 +212,7 @@ export default function ProviderEditPage() {
             allowCustomValue
             value={provider.baseUrl}
             placeholder={baseUrlPlaceholder(provider.type)}
-            options={baseUrlPresets(provider.type)}
+            options={baseUrlOptions(provider.type)}
             onChange={value => setField("baseUrl", value)}
           />
         </Field>
diff --git a/web/src/pages/ProviderListPage.tsx 
b/web/src/pages/ProviderListPage.tsx
index aab1b56..621979d 100644
--- a/web/src/pages/ProviderListPage.tsx
+++ b/web/src/pages/ProviderListPage.tsx
@@ -39,6 +39,7 @@ import {ErrorState} from "@/components/shared/empty-state";
 import {Field, FormDialog} from "@/components/shared/form-dialog";
 import {PageContainer, PageHeader} from "@/components/shared/page-header";
 import {PasswordInput} from "@/components/shared/password-input";
+import {baseUrlOptions, providerTypeOptions} from 
"@/components/shared/brand-options";
 import {SearchSelect, SimpleSelect} from "@/components/shared/simple-select";
 import {MessageAlert} from "@/components/ui/alert";
 import {Badge} from "@/components/ui/badge";
@@ -51,7 +52,6 @@ import {
   authProvider,
   authClient,
   baseUrlPlaceholder,
-  baseUrlPresets,
   customSource,
   providerIdOf,
   providerSlug,
@@ -589,7 +589,7 @@ export default function ProviderListPage({account}: 
{account: Account}) {
                   id="provider-base-url"
                   value={form.baseUrl}
                   placeholder={baseUrlPlaceholder(form.type)}
-                  options={baseUrlPresets(form.type)}
+                  options={baseUrlOptions(form.type)}
                   onChange={value => setFormField("baseUrl", value)}
                 />
               </Field>
@@ -670,11 +670,7 @@ export default function ProviderListPage({account}: 
{account: Account}) {
                 <SimpleSelect
                   value={form.type}
                   onChange={value => setFormField("type", value)}
-                  options={[
-                    {label: "OpenAI", value: "openai"},
-                    {label: "Anthropic", value: "anthropic"},
-                    {label: "Custom", value: "custom"},
-                  ]}
+                  options={providerTypeOptions}
                 />
               </Field>
               {source.key === customSource ? null : (
@@ -684,7 +680,7 @@ export default function ProviderListPage({account}: 
{account: Account}) {
                     id="provider-base-url"
                     value={form.baseUrl}
                     placeholder={baseUrlPlaceholder(form.type)}
-                    options={baseUrlPresets(form.type)}
+                    options={baseUrlOptions(form.type)}
                     onChange={value => setFormField("baseUrl", value)}
                   />
                 </Field>
diff --git a/web/src/pages/UsagePage.tsx b/web/src/pages/UsagePage.tsx
index 56f80e6..fc36cbc 100644
--- a/web/src/pages/UsagePage.tsx
+++ b/web/src/pages/UsagePage.tsx
@@ -24,6 +24,7 @@ import {UsageBreakdown} from 
"@/components/usage/usage-breakdown";
 import {UsageHero} from "@/components/usage/usage-hero";
 import {UsageTrendChart} from "@/components/usage/usage-trend-chart";
 import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {agentOption} from "@/components/shared/brand-options";
 import {SimpleSelect} from "@/components/shared/simple-select";
 import {UnauthorizedResult} from "@/components/shared/misc";
 import {MessageAlert} from "@/components/ui/alert";
@@ -285,7 +286,7 @@ export default function UsagePage({account}: {account: 
Account}) {
               onChange={setAgent}
               options={[
                 {label: i18next.t("usage:Every agent"), value: ""},
-                ...knownAgents.map(name => ({label: name, value: name})),
+                ...knownAgents.map(name => agentOption(name)),
               ]}
             />
             <SimpleSelect


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to