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:
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user