Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 41 additions & 49 deletions apps/web/src/components/settings/AddProviderInstanceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
import { cn } from "../../lib/utils";
import { normalizeProviderAccentColor } from "../../providerInstances";
import { Button } from "../ui/button";
import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon } from "../Icons";
import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons";
import {
Dialog,
DialogDescription,
Expand All @@ -26,7 +26,8 @@ import { Badge } from "../ui/badge";
import { Input } from "../ui/input";
import { RadioGroup } from "../ui/radio-group";
import { toastManager } from "../ui/toast";
import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS, type DriverOption } from "./providerDriverMeta";
import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta";
import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm";

const PROVIDER_ACCENT_SWATCHES = [
"#2563eb",
Expand Down Expand Up @@ -61,30 +62,33 @@ function deriveInstanceId(driver: ProviderDriverKind, label: string): string {
const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");
const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!;
const COMING_SOON_DRIVER_OPTIONS: readonly DriverOption[] = [
const EMPTY_CONFIG_DRAFT: Record<string, unknown> = {};
interface ComingSoonDriverOption {
readonly value: ProviderDriverKind;
readonly label: string;
readonly icon: Icon;
}

const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [
{
value: ProviderDriverKind.make("githubCopilot"),
label: "Github Copilot",
icon: GithubCopilotIcon,
fields: [],
},
{
value: ProviderDriverKind.make("gemini"),
label: "Gemini",
icon: Gemini,
fields: [],
},
{
value: ProviderDriverKind.make("acpRegistry"),
label: "ACP Registry",
icon: ACPRegistryIcon,
fields: [],
},
{
value: ProviderDriverKind.make("piAgent"),
label: "Pi Agent",
icon: PiAgentIcon,
fields: [],
},
];

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

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

const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION;
const driverSettingsFields = useMemo(
() => deriveProviderSettingsFields(driverOption),
[driverOption],
);
const instanceIdError = validateInstanceId(instanceId, existingIds);
const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null;
const previewLabel = label.trim() || `${driverOption.label} Workspace`;
const wizardSteps = ["Driver", "Identity", "Config"] as const;
const wizardStepSummaries = [driverOption.label, previewLabel, null] as const;

const getFieldValue = useCallback(
(fieldKey: string) => fieldValues[`${driver}:${fieldKey}`] ?? "",
[driver, fieldValues],
);

const setFieldValue = useCallback(
(fieldKey: string, value: string) => {
setFieldValues((existing) => ({
...existing,
[`${driver}:${fieldKey}`]: value,
}));
const configDraft = configByDriver[driver] ?? EMPTY_CONFIG_DRAFT;
const setConfigDraft = useCallback(
(config: Record<string, unknown> | undefined) => {
setConfigByDriver((existing) => {
const next = { ...existing };
if (config === undefined || Object.keys(config).length === 0) {
delete next[driver];
} else {
next[driver] = config;
}
return next;
});
},
[driver],
);
Expand All @@ -178,13 +186,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
setHasAttemptedSubmit(true);
if (instanceIdError !== null) return;

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

Expand Down Expand Up @@ -222,7 +224,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
}, [
driver,
driverOption,
fieldValues,
configByDriver,
instanceId,
instanceIdError,
label,
Expand Down Expand Up @@ -433,25 +435,15 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
</span>
</div>

{driverOption.fields.length > 0 ? (
{driverSettingsFields.length > 0 ? (
<div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}>
{driverOption.fields.map((field) => (
<label key={field.key} className="grid gap-1.5">
<span className="text-xs font-medium text-foreground">{field.label}</span>
<Input
className="bg-background"
type={field.type === "password" ? "password" : undefined}
autoComplete={field.type === "password" ? "off" : undefined}
placeholder={field.placeholder}
value={getFieldValue(field.key)}
onChange={(event) => setFieldValue(field.key, event.target.value)}
spellCheck={false}
/>
{field.description ? (
<span className="text-[11px] text-muted-foreground">{field.description}</span>
) : null}
</label>
))}
<ProviderSettingsForm
definition={driverOption}
value={configDraft}
idPrefix={`add-provider-${driver}`}
variant="dialog"
onChange={setConfigDraft}
/>
</div>
) : wizardStep === 2 ? (
<div className="grid gap-2">
Expand Down
81 changes: 14 additions & 67 deletions apps/web/src/components/settings/ProviderInstanceCard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
isProviderDriverKind,
type ProviderInstanceConfig,
Expand All @@ -21,6 +21,7 @@ import { DraftInput } from "../ui/draft-input";
import { Switch } from "../ui/switch";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import type { DriverOption } from "./providerDriverMeta";
import { ProviderSettingsForm } from "./ProviderSettingsForm";
import { ProviderModelsSection } from "./ProviderModelsSection";
import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
import {
Expand Down Expand Up @@ -86,20 +87,6 @@ function redactedEmailPlaceholder(email: string): string {
}).join("");
}

/**
* Read a string value at `key` from the opaque per-driver config blob.
* Returns an empty string when the key is missing or the stored value is
* not a string. The permissive shape reflects that `config` is
* `Schema.Unknown` at the contract boundary — forks may populate it with
* non-string values that the built-in UI should round-trip without
* throwing.
*/
function readConfigString(config: unknown, key: string): string {
if (config === null || typeof config !== "object") return "";
const value = (config as Record<string, unknown>)[key];
return typeof value === "string" ? value : "";
}

/**
* Read a string[] at `key` from the opaque config blob, filtering out
* non-string entries. Used for `customModels`, which is always typed as
Expand All @@ -113,35 +100,9 @@ function readConfigStringArray(config: unknown, key: string): ReadonlyArray<stri
return value.filter((entry): entry is string => typeof entry === "string");
}

/**
* Produce the next config blob after setting `key` to `value`. Empty
* strings drop the key so server defaults stay in effect, mirroring the
* save-time normalization in `AddProviderInstanceDialog`. Returns
* `undefined` when the resulting blob has no keys, which matches
* `ProviderInstanceConfig.config` being optional.
*
* Non-string values already stored in the blob are carried through
* verbatim so fork-owned fields survive edits made through this UI.
*/
function nextConfigBlobWithString(
config: unknown,
key: string,
value: string,
): Record<string, unknown> | undefined {
const base: Record<string, unknown> =
config !== null && typeof config === "object" ? { ...(config as Record<string, unknown>) } : {};
const trimmed = value.trim();
if (trimmed.length > 0) {
base[key] = value;
} else {
delete base[key];
}
return Object.keys(base).length > 0 ? base : undefined;
}

/**
* Set `key` to an arbitrary value on the opaque config blob. Unlike
* `nextConfigBlobWithString`, does not drop empty-looking values — the
* provider settings field updates, does not drop empty-looking values — the
* caller is responsible for deciding whether an empty array / empty
* object should be stored explicitly (e.g. `customModels: []` is a
* meaningful "user cleared their custom list" state distinct from
Expand Down Expand Up @@ -473,7 +434,7 @@ interface ProviderInstanceCardProps {
* default slots supply a reset-to-factory control here; custom instances
* omit it.
*/
readonly headerAction?: React.ReactNode | undefined;
readonly headerAction?: ReactNode | undefined;
readonly hiddenModels: ReadonlyArray<string>;
readonly favoriteModels: ReadonlyArray<string>;
readonly modelOrder: ReadonlyArray<string>;
Expand Down Expand Up @@ -585,8 +546,7 @@ export function ProviderInstanceCard({
);
};

const updateConfigField = (key: string, value: string) => {
const nextConfig = nextConfigBlobWithString(instance.config, key, value);
const updateConfig = (nextConfig: Record<string, unknown> | undefined) => {
const { config: _omit, ...rest } = instance;
onUpdate(
nextConfig !== undefined
Expand Down Expand Up @@ -759,28 +719,15 @@ export function ProviderInstanceCard({
/>
</div>

{driverOption?.fields.map((field) => (
<div key={field.key} className="border-t border-border/60 px-4 py-3 sm:px-5">
<label htmlFor={`provider-instance-${instanceId}-${field.key}`} className="block">
<span className="text-xs font-medium text-foreground">{field.label}</span>
<DraftInput
id={`provider-instance-${instanceId}-${field.key}`}
className="mt-1.5"
type={field.type === "password" ? "password" : undefined}
autoComplete={field.type === "password" ? "off" : undefined}
value={readConfigString(instance.config, field.key)}
onCommit={(next) => updateConfigField(field.key, next)}
placeholder={field.placeholder}
spellCheck={false}
/>
{field.description ? (
<span className="mt-1 block text-xs text-muted-foreground">
{field.description}
</span>
) : null}
</label>
</div>
))}
{driverOption ? (
<ProviderSettingsForm
definition={driverOption}
value={instance.config}
idPrefix={`provider-instance-${instanceId}`}
variant="card"
onChange={updateConfig}
/>
) : null}

{driverOption !== undefined ? (
<ProviderModelsSection
Expand Down
Loading
Loading