feat: left-right layout for the Registry Editor

With multiple providers configured, the vertical stack required long
scrolling. The left pane now lists providers with enabled markers and
per-model availability dots plus the template picker; the right pane
shows the selected provider's editor, auto-selecting newly added
providers and clamping selection on remove.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-07-21 22:59:11 +08:00
parent 2bf1127af2
commit cec1fa04d7
2 changed files with 181 additions and 82 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ export function ConfigDialog({ open, onOpenChange }: ConfigDialogProps) {
open={open}
onOpenChange={onOpenChange}
>
<DialogContent className="sm:max-w-5xl">
<DialogContent className="sm:max-w-6xl">
<DialogHeader>
<DialogTitle>Model registry</DialogTitle>
<DialogDescription>
+180 -81
View File
@@ -159,6 +159,32 @@ function AvailabilityBadge({
);
}
/** Compact per-model status dot for the provider list (left pane). */
function ModelStatusDot({
data,
ref_,
}: {
data: GetModelRegistryResponse;
ref_: ModelRef;
}) {
const status = availabilityFor(data, ref_);
const state = status?.state ?? null;
const color =
state === null
? "bg-muted-foreground/40"
: state === "enabled" || state === "verified"
? "bg-emerald-600"
: state === "verification_failed"
? "bg-destructive"
: "bg-[var(--color-warning)]";
return (
<span
className={`inline-block size-2 rounded-full ${color}`}
title={`${ref_.model_key}: ${state ?? "new"}`}
/>
);
}
function ModelEditor({
data,
spec,
@@ -276,7 +302,7 @@ function ModelEditor({
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<Field label="Limit mode">
<select
className={inputClass}
@@ -658,7 +684,7 @@ function ProviderEditor({
</>
)}
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<NumberField
label="Timeout (s)"
value={provider.runtime.timeout_seconds}
@@ -790,6 +816,7 @@ export function RegistryEditor() {
{}
);
const [selectedTemplate, setSelectedTemplate] = useState<string>("zhipu-glm");
const [selectedProviderIndex, setSelectedProviderIndex] = useState(0);
const load = useCallback(async () => {
setLoadError(null);
@@ -971,6 +998,12 @@ export function RegistryEditor() {
if (!provider_id || !model_key) return null;
return { provider_id, model_key };
};
const selectedIndex =
draft.providers.length === 0
? -1
: Math.min(selectedProviderIndex, draft.providers.length - 1);
const selectedProvider =
selectedIndex >= 0 ? draft.providers[selectedIndex] : null;
return (
<div className="space-y-4">
@@ -1039,86 +1072,152 @@ export function RegistryEditor() {
</Field>
</div>
{draft.providers.map((provider, index) => (
<ProviderEditor
key={`${index}-${provider.id}`}
data={data}
provider={provider}
index={index}
dirty={dirty}
testing={testing}
testOutcomes={testOutcomes}
stagedCredential={
provider.auth.credential_id
? credentialWrites.get(provider.auth.credential_id)
: undefined
}
onCredentialStage={(credentialId, secret) =>
setCredentialWrites((current) => {
const next = new Map(current);
if (secret) next.set(credentialId, secret);
else next.delete(credentialId);
return next;
})
}
onChange={(next) =>
setDraft({
...draft,
providers: draft.providers.map((current, i) =>
i === index ? next : current
),
})
}
onRemove={() =>
setDraft({
...draft,
providers: draft.providers.filter((_, i) => i !== index),
})
}
onTestModel={(ref) => void testModel(ref)}
/>
))}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[14rem_1fr]">
<div className="space-y-3">
<div className="space-y-1" role="listbox" aria-label="Providers">
{draft.providers.length === 0 && (
<p className="text-xs text-muted-foreground">
No providers yet. Add one from a template below.
</p>
)}
{draft.providers.map((provider, index) => {
const selected = index === selectedIndex;
return (
<button
key={`${index}-${provider.id}`}
type="button"
role="option"
aria-selected={selected}
onClick={() => setSelectedProviderIndex(index)}
className={`w-full space-y-1 rounded-md border px-3 py-2 text-left text-sm transition-colors ${
selected
? "border-primary bg-accent"
: "border-border hover:bg-accent/50"
}`}
>
<span className="flex items-center gap-2">
<span
className={`inline-block size-2 shrink-0 rounded-full ${
provider.enabled ? "bg-emerald-600" : "bg-muted-foreground/40"
}`}
title={provider.enabled ? "provider enabled" : "provider disabled"}
/>
<span className="truncate font-medium">
{provider.name || provider.id}
</span>
</span>
<span className="flex items-center justify-between gap-2 pl-4 text-[11px] text-muted-foreground">
<span className="truncate font-mono">{provider.id}</span>
<span className="flex items-center gap-1">
{provider.models.map((model) => (
<ModelStatusDot
key={model.key}
data={data}
ref_={{ provider_id: provider.id, model_key: model.key }}
/>
))}
</span>
</span>
</button>
);
})}
</div>
<div className="space-y-2 border-t border-border pt-3">
<select
className={inputClass}
value={selectedTemplate}
onChange={(event) => setSelectedTemplate(event.target.value)}
aria-label="Provider template"
>
{PROVIDER_TEMPLATES.map((template) => (
<option key={template.id} value={template.id}>
{template.label}
</option>
))}
<option value="blank">Blank provider</option>
</select>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
const template =
PROVIDER_TEMPLATES.find(
(entry) => entry.id === selectedTemplate
) ?? null;
setDraft({
...draft,
providers: [
...draft.providers,
providerDraftFromTemplate(
template,
data.adapter_specs,
new Set(draft.providers.map((provider) => provider.id))
),
],
});
setSelectedProviderIndex(draft.providers.length);
}}
>
Add provider
</Button>
<p className="text-[11px] text-muted-foreground">
Templates only prefill the adapter, Base URL, and suggested
models; a saved provider is an ordinary profile.
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<select
className={inputClass + " max-w-56"}
value={selectedTemplate}
onChange={(event) => setSelectedTemplate(event.target.value)}
aria-label="Provider template"
>
{PROVIDER_TEMPLATES.map((template) => (
<option key={template.id} value={template.id}>
{template.label}
</option>
))}
<option value="blank">Blank provider</option>
</select>
<Button
type="button"
variant="outline"
onClick={() => {
const template =
PROVIDER_TEMPLATES.find((entry) => entry.id === selectedTemplate) ??
null;
setDraft({
...draft,
providers: [
...draft.providers,
providerDraftFromTemplate(
template,
data.adapter_specs,
new Set(draft.providers.map((provider) => provider.id))
),
],
});
}}
>
Add provider
</Button>
<span className="text-xs text-muted-foreground">
Templates only prefill the adapter, Base URL, and suggested models;
a saved provider is an ordinary profile.
</span>
<div>
{selectedProvider === null ? (
<p className="rounded-lg border border-dashed border-border px-4 py-8 text-center text-sm text-muted-foreground">
Select a provider on the left, or add one from a template.
</p>
) : (
<ProviderEditor
key={`${selectedIndex}-${selectedProvider.id}`}
data={data}
provider={selectedProvider}
index={selectedIndex}
dirty={dirty}
testing={testing}
testOutcomes={testOutcomes}
stagedCredential={
selectedProvider.auth.credential_id
? credentialWrites.get(selectedProvider.auth.credential_id)
: undefined
}
onCredentialStage={(credentialId, secret) =>
setCredentialWrites((current) => {
const next = new Map(current);
if (secret) next.set(credentialId, secret);
else next.delete(credentialId);
return next;
})
}
onChange={(next) =>
setDraft({
...draft,
providers: draft.providers.map((current, i) =>
i === selectedIndex ? next : current
),
})
}
onRemove={() => {
setDraft({
...draft,
providers: draft.providers.filter((_, i) => i !== selectedIndex),
});
setSelectedProviderIndex((current) =>
selectedIndex < current
? current - 1
: Math.max(0, selectedIndex - 1)
);
}}
onTestModel={(ref) => void testModel(ref)}
/>
)}
</div>
</div>
{saveIssues && saveIssues.details.length > 0 && (