Skip to content

Commit 460d9c3

Browse files
juliusmarmingeJulius Marminge
andauthored
Refactor provider settings to declarative metadata (#2452)
Co-authored-by: Julius Marminge <julius@macmini.local>
1 parent cb8015a commit 460d9c3

7 files changed

Lines changed: 738 additions & 296 deletions

File tree

apps/web/src/components/settings/AddProviderInstanceDialog.tsx

Lines changed: 41 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
1313
import { cn } from "../../lib/utils";
1414
import { normalizeProviderAccentColor } from "../../providerInstances";
1515
import { Button } from "../ui/button";
16-
import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon } from "../Icons";
16+
import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons";
1717
import {
1818
Dialog,
1919
DialogDescription,
@@ -26,7 +26,8 @@ import { Badge } from "../ui/badge";
2626
import { Input } from "../ui/input";
2727
import { RadioGroup } from "../ui/radio-group";
2828
import { toastManager } from "../ui/toast";
29-
import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS, type DriverOption } from "./providerDriverMeta";
29+
import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta";
30+
import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm";
3031

3132
const PROVIDER_ACCENT_SWATCHES = [
3233
"#2563eb",
@@ -61,30 +62,33 @@ function deriveInstanceId(driver: ProviderDriverKind, label: string): string {
6162
const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
6263
const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");
6364
const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!;
64-
const COMING_SOON_DRIVER_OPTIONS: readonly DriverOption[] = [
65+
const EMPTY_CONFIG_DRAFT: Record<string, unknown> = {};
66+
interface ComingSoonDriverOption {
67+
readonly value: ProviderDriverKind;
68+
readonly label: string;
69+
readonly icon: Icon;
70+
}
71+
72+
const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [
6573
{
6674
value: ProviderDriverKind.make("githubCopilot"),
6775
label: "Github Copilot",
6876
icon: GithubCopilotIcon,
69-
fields: [],
7077
},
7178
{
7279
value: ProviderDriverKind.make("gemini"),
7380
label: "Gemini",
7481
icon: Gemini,
75-
fields: [],
7682
},
7783
{
7884
value: ProviderDriverKind.make("acpRegistry"),
7985
label: "ACP Registry",
8086
icon: ACPRegistryIcon,
81-
fields: [],
8287
},
8388
{
8489
value: ProviderDriverKind.make("piAgent"),
8590
label: "Pi Agent",
8691
icon: PiAgentIcon,
87-
fields: [],
8892
},
8993
];
9094

@@ -118,10 +122,9 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
118122
const [accentColor, setAccentColor] = useState<string>("");
119123
const [instanceId, setInstanceId] = useState("");
120124
const [instanceIdDirty, setInstanceIdDirty] = useState(false);
121-
// Driver-specific field values keyed by `${driver}:${fieldKey}` so toggling
122-
// between drivers during the same dialog session doesn't lose in-progress
123-
// input. Only the active driver's values are persisted on save.
124-
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
125+
// Driver-specific config drafts keyed by driver so toggling between drivers
126+
// during the same dialog session does not lose in-progress input.
127+
const [configByDriver, setConfigByDriver] = useState<Record<string, Record<string, unknown>>>({});
125128
// Errors are suppressed until the user has tried to submit once. After that
126129
// they update live so fixing the problem clears the message in place.
127130
const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
@@ -141,7 +144,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
141144
setInstanceId("");
142145
setWizardStep(0);
143146
setInstanceIdDirty(false);
144-
setFieldValues({});
147+
setConfigByDriver({});
145148
setHasAttemptedSubmit(false);
146149
}, [open]);
147150

@@ -153,23 +156,28 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
153156
}, [driver, label, instanceIdDirty]);
154157

155158
const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION;
159+
const driverSettingsFields = useMemo(
160+
() => deriveProviderSettingsFields(driverOption),
161+
[driverOption],
162+
);
156163
const instanceIdError = validateInstanceId(instanceId, existingIds);
157164
const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null;
158165
const previewLabel = label.trim() || `${driverOption.label} Workspace`;
159166
const wizardSteps = ["Driver", "Identity", "Config"] as const;
160167
const wizardStepSummaries = [driverOption.label, previewLabel, null] as const;
161168

162-
const getFieldValue = useCallback(
163-
(fieldKey: string) => fieldValues[`${driver}:${fieldKey}`] ?? "",
164-
[driver, fieldValues],
165-
);
166-
167-
const setFieldValue = useCallback(
168-
(fieldKey: string, value: string) => {
169-
setFieldValues((existing) => ({
170-
...existing,
171-
[`${driver}:${fieldKey}`]: value,
172-
}));
169+
const configDraft = configByDriver[driver] ?? EMPTY_CONFIG_DRAFT;
170+
const setConfigDraft = useCallback(
171+
(config: Record<string, unknown> | undefined) => {
172+
setConfigByDriver((existing) => {
173+
const next = { ...existing };
174+
if (config === undefined || Object.keys(config).length === 0) {
175+
delete next[driver];
176+
} else {
177+
next[driver] = config;
178+
}
179+
return next;
180+
});
173181
},
174182
[driver],
175183
);
@@ -178,13 +186,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
178186
setHasAttemptedSubmit(true);
179187
if (instanceIdError !== null) return;
180188

181-
// Build the config blob from non-empty driver-specific field values.
182-
// Empty strings are dropped so defaults remain in effect on the server.
183-
const config: Record<string, string> = {};
184-
for (const field of driverOption.fields) {
185-
const value = (fieldValues[`${driver}:${field.key}`] ?? "").trim();
186-
if (value.length > 0) config[field.key] = value;
187-
}
189+
const config = configByDriver[driver] ?? {};
188190
const hasConfig = Object.keys(config).length > 0;
189191
const normalizedAccentColor = normalizeProviderAccentColor(accentColor);
190192

@@ -222,7 +224,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
222224
}, [
223225
driver,
224226
driverOption,
225-
fieldValues,
227+
configByDriver,
226228
instanceId,
227229
instanceIdError,
228230
label,
@@ -433,25 +435,15 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
433435
</span>
434436
</div>
435437

436-
{driverOption.fields.length > 0 ? (
438+
{driverSettingsFields.length > 0 ? (
437439
<div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}>
438-
{driverOption.fields.map((field) => (
439-
<label key={field.key} className="grid gap-1.5">
440-
<span className="text-xs font-medium text-foreground">{field.label}</span>
441-
<Input
442-
className="bg-background"
443-
type={field.type === "password" ? "password" : undefined}
444-
autoComplete={field.type === "password" ? "off" : undefined}
445-
placeholder={field.placeholder}
446-
value={getFieldValue(field.key)}
447-
onChange={(event) => setFieldValue(field.key, event.target.value)}
448-
spellCheck={false}
449-
/>
450-
{field.description ? (
451-
<span className="text-[11px] text-muted-foreground">{field.description}</span>
452-
) : null}
453-
</label>
454-
))}
440+
<ProviderSettingsForm
441+
definition={driverOption}
442+
value={configDraft}
443+
idPrefix={`add-provider-${driver}`}
444+
variant="dialog"
445+
onChange={setConfigDraft}
446+
/>
455447
</div>
456448
) : wizardStep === 2 ? (
457449
<div className="grid gap-2">

apps/web/src/components/settings/ProviderInstanceCard.tsx

Lines changed: 14 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";
4-
import { useEffect, useMemo, useState } from "react";
4+
import { useEffect, useMemo, useState, type ReactNode } from "react";
55
import {
66
isProviderDriverKind,
77
type ProviderInstanceConfig,
@@ -21,6 +21,7 @@ import { DraftInput } from "../ui/draft-input";
2121
import { Switch } from "../ui/switch";
2222
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2323
import type { DriverOption } from "./providerDriverMeta";
24+
import { ProviderSettingsForm } from "./ProviderSettingsForm";
2425
import { ProviderModelsSection } from "./ProviderModelsSection";
2526
import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
2627
import {
@@ -86,20 +87,6 @@ function redactedEmailPlaceholder(email: string): string {
8687
}).join("");
8788
}
8889

89-
/**
90-
* Read a string value at `key` from the opaque per-driver config blob.
91-
* Returns an empty string when the key is missing or the stored value is
92-
* not a string. The permissive shape reflects that `config` is
93-
* `Schema.Unknown` at the contract boundary — forks may populate it with
94-
* non-string values that the built-in UI should round-trip without
95-
* throwing.
96-
*/
97-
function readConfigString(config: unknown, key: string): string {
98-
if (config === null || typeof config !== "object") return "";
99-
const value = (config as Record<string, unknown>)[key];
100-
return typeof value === "string" ? value : "";
101-
}
102-
10390
/**
10491
* Read a string[] at `key` from the opaque config blob, filtering out
10592
* non-string entries. Used for `customModels`, which is always typed as
@@ -113,35 +100,9 @@ function readConfigStringArray(config: unknown, key: string): ReadonlyArray<stri
113100
return value.filter((entry): entry is string => typeof entry === "string");
114101
}
115102

116-
/**
117-
* Produce the next config blob after setting `key` to `value`. Empty
118-
* strings drop the key so server defaults stay in effect, mirroring the
119-
* save-time normalization in `AddProviderInstanceDialog`. Returns
120-
* `undefined` when the resulting blob has no keys, which matches
121-
* `ProviderInstanceConfig.config` being optional.
122-
*
123-
* Non-string values already stored in the blob are carried through
124-
* verbatim so fork-owned fields survive edits made through this UI.
125-
*/
126-
function nextConfigBlobWithString(
127-
config: unknown,
128-
key: string,
129-
value: string,
130-
): Record<string, unknown> | undefined {
131-
const base: Record<string, unknown> =
132-
config !== null && typeof config === "object" ? { ...(config as Record<string, unknown>) } : {};
133-
const trimmed = value.trim();
134-
if (trimmed.length > 0) {
135-
base[key] = value;
136-
} else {
137-
delete base[key];
138-
}
139-
return Object.keys(base).length > 0 ? base : undefined;
140-
}
141-
142103
/**
143104
* Set `key` to an arbitrary value on the opaque config blob. Unlike
144-
* `nextConfigBlobWithString`, does not drop empty-looking values — the
105+
* provider settings field updates, does not drop empty-looking values — the
145106
* caller is responsible for deciding whether an empty array / empty
146107
* object should be stored explicitly (e.g. `customModels: []` is a
147108
* meaningful "user cleared their custom list" state distinct from
@@ -473,7 +434,7 @@ interface ProviderInstanceCardProps {
473434
* default slots supply a reset-to-factory control here; custom instances
474435
* omit it.
475436
*/
476-
readonly headerAction?: React.ReactNode | undefined;
437+
readonly headerAction?: ReactNode | undefined;
477438
readonly hiddenModels: ReadonlyArray<string>;
478439
readonly favoriteModels: ReadonlyArray<string>;
479440
readonly modelOrder: ReadonlyArray<string>;
@@ -585,8 +546,7 @@ export function ProviderInstanceCard({
585546
);
586547
};
587548

588-
const updateConfigField = (key: string, value: string) => {
589-
const nextConfig = nextConfigBlobWithString(instance.config, key, value);
549+
const updateConfig = (nextConfig: Record<string, unknown> | undefined) => {
590550
const { config: _omit, ...rest } = instance;
591551
onUpdate(
592552
nextConfig !== undefined
@@ -759,28 +719,15 @@ export function ProviderInstanceCard({
759719
/>
760720
</div>
761721

762-
{driverOption?.fields.map((field) => (
763-
<div key={field.key} className="border-t border-border/60 px-4 py-3 sm:px-5">
764-
<label htmlFor={`provider-instance-${instanceId}-${field.key}`} className="block">
765-
<span className="text-xs font-medium text-foreground">{field.label}</span>
766-
<DraftInput
767-
id={`provider-instance-${instanceId}-${field.key}`}
768-
className="mt-1.5"
769-
type={field.type === "password" ? "password" : undefined}
770-
autoComplete={field.type === "password" ? "off" : undefined}
771-
value={readConfigString(instance.config, field.key)}
772-
onCommit={(next) => updateConfigField(field.key, next)}
773-
placeholder={field.placeholder}
774-
spellCheck={false}
775-
/>
776-
{field.description ? (
777-
<span className="mt-1 block text-xs text-muted-foreground">
778-
{field.description}
779-
</span>
780-
) : null}
781-
</label>
782-
</div>
783-
))}
722+
{driverOption ? (
723+
<ProviderSettingsForm
724+
definition={driverOption}
725+
value={instance.config}
726+
idPrefix={`provider-instance-${instanceId}`}
727+
variant="card"
728+
onChange={updateConfig}
729+
/>
730+
) : null}
784731

785732
{driverOption !== undefined ? (
786733
<ProviderModelsSection

0 commit comments

Comments
 (0)