diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index affa35ff260..4b43f4b54ae 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -2,7 +2,7 @@ import { CheckIcon } from "lucide-react"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useReducer } from "react"; import { ProviderInstanceId, ProviderDriverKind, @@ -64,6 +64,7 @@ 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 EMPTY_CONFIG_DRAFT: Record = {}; +const WIZARD_STEPS = ["Driver", "Identity", "Config"] as const; interface ComingSoonDriverOption { readonly value: ProviderDriverKind; readonly label: string; @@ -113,78 +114,132 @@ interface AddProviderInstanceDialogProps { onOpenChange: (open: boolean) => void; } +interface AddProviderInstanceDraftState { + readonly wizardStep: number; + readonly driver: ProviderDriverKind; + readonly label: string; + readonly accentColor: string; + readonly manualInstanceId: string; + readonly instanceIdDirty: boolean; + readonly configByDriver: Record>; + readonly hasAttemptedSubmit: boolean; +} + +type AddProviderInstanceDraftAction = + | { readonly type: "setWizardStep"; readonly step: number } + | { readonly type: "setDriver"; readonly driver: ProviderDriverKind } + | { readonly type: "setLabel"; readonly label: string } + | { readonly type: "setAccentColor"; readonly accentColor: string } + | { readonly type: "setManualInstanceId"; readonly instanceId: string } + | { + readonly type: "setConfigDraft"; + readonly driver: ProviderDriverKind; + readonly config: Record | undefined; + } + | { readonly type: "markAttemptedSubmit" }; + +function createInitialAddProviderInstanceDraftState(): AddProviderInstanceDraftState { + return { + wizardStep: 0, + driver: DEFAULT_DRIVER_KIND, + label: "", + accentColor: "", + manualInstanceId: "", + instanceIdDirty: false, + configByDriver: {}, + hasAttemptedSubmit: false, + }; +} + +function addProviderInstanceDraftReducer( + state: AddProviderInstanceDraftState, + action: AddProviderInstanceDraftAction, +): AddProviderInstanceDraftState { + switch (action.type) { + case "setWizardStep": + return state.wizardStep === action.step ? state : { ...state, wizardStep: action.step }; + case "setDriver": + return state.driver === action.driver ? state : { ...state, driver: action.driver }; + case "setLabel": + return state.label === action.label ? state : { ...state, label: action.label }; + case "setAccentColor": + return state.accentColor === action.accentColor + ? state + : { ...state, accentColor: action.accentColor }; + case "setManualInstanceId": + return state.manualInstanceId === action.instanceId && state.instanceIdDirty + ? state + : { ...state, manualInstanceId: action.instanceId, instanceIdDirty: true }; + case "setConfigDraft": { + const next = { ...state.configByDriver }; + if (action.config === undefined || Object.keys(action.config).length === 0) { + delete next[action.driver]; + } else { + next[action.driver] = action.config; + } + return { ...state, configByDriver: next }; + } + case "markAttemptedSubmit": + return state.hasAttemptedSubmit ? state : { ...state, hasAttemptedSubmit: true }; + } +} + export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) { + return ( + + {open ? : null} + + ); +} + +function AddProviderInstanceDialogContent({ + onOpenChange, +}: Pick) { const settings = useSettings(); const { updateSettings } = useUpdateSettings(); - - const [wizardStep, setWizardStep] = useState(0); - const [driver, setDriver] = useState(DEFAULT_DRIVER_KIND); - const [label, setLabel] = useState(""); - const [accentColor, setAccentColor] = useState(""); - const [instanceId, setInstanceId] = useState(""); - const [instanceIdDirty, setInstanceIdDirty] = useState(false); - // 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>>({}); - // 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); + const [draft, dispatch] = useReducer( + addProviderInstanceDraftReducer, + undefined, + createInitialAddProviderInstanceDraftState, + ); + const { + wizardStep, + driver, + label, + accentColor, + manualInstanceId, + instanceIdDirty, + configByDriver, + hasAttemptedSubmit, + } = draft; const existingIds = useMemo( () => new Set(Object.keys(settings.providerInstances ?? {})), [settings.providerInstances], ); - // Reset the form every time the dialog opens so each creation starts - // from a clean slate. - useEffect(() => { - if (!open) return; - setDriver(DEFAULT_DRIVER_KIND); - setLabel(""); - setAccentColor(""); - setInstanceId(""); - setWizardStep(0); - setInstanceIdDirty(false); - setConfigByDriver({}); - setHasAttemptedSubmit(false); - }, [open]); - - // Auto-derive the instance id from driver + label until the user types - // in the Instance ID field directly (after which they own its value). - useEffect(() => { - if (instanceIdDirty) return; - setInstanceId(deriveInstanceId(driver, label)); - }, [driver, label, instanceIdDirty]); - const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION; const driverSettingsFields = useMemo( () => deriveProviderSettingsFields(driverOption), [driverOption], ); + const derivedInstanceId = deriveInstanceId(driver, label); + const instanceId = instanceIdDirty ? manualInstanceId : derivedInstanceId; 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 configDraft = configByDriver[driver] ?? EMPTY_CONFIG_DRAFT; const setConfigDraft = useCallback( (config: Record | undefined) => { - setConfigByDriver((existing) => { - const next = { ...existing }; - if (config === undefined || Object.keys(config).length === 0) { - delete next[driver]; - } else { - next[driver] = config; - } - return next; - }); + dispatch({ type: "setConfigDraft", driver, config }); }, [driver], ); const handleSave = useCallback(() => { - setHasAttemptedSubmit(true); + dispatch({ type: "markAttemptedSubmit" }); if (instanceIdError !== null) return; const config = configByDriver[driver] ?? {}; @@ -236,256 +291,392 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns ]); return ( - - -
- - Add provider instance - - Configure an additional provider instance — for example, a second Codex install - pointed at a different workspace. - -
- {wizardSteps.map((step, index) => ( + +
+ dispatch({ type: "setWizardStep", step })} + /> + dispatch({ type: "setLabel", label: nextLabel })} + instanceId={instanceId} + instanceIdError={instanceIdError} + showInstanceIdError={showInstanceIdError} + onInstanceIdChange={(nextInstanceId) => + dispatch({ type: "setManualInstanceId", instanceId: nextInstanceId }) + } + accentColor={accentColor} + onAccentColorChange={(nextAccentColor) => + dispatch({ type: "setAccentColor", accentColor: nextAccentColor }) + } + onDriverChange={(nextDriver) => dispatch({ type: "setDriver", driver: nextDriver })} + configDraft={configDraft} + onConfigDraftChange={setConfigDraft} + /> + { + if (wizardStep === 0) { + onOpenChange(false); + return; + } + dispatch({ type: "setWizardStep", step: Math.max(0, wizardStep - 1) }); + }} + onNext={() => + dispatch({ + type: "setWizardStep", + step: Math.min(WIZARD_STEPS.length - 1, wizardStep + 1), + }) + } + onSave={handleSave} + /> +
+
+ ); +} + +function AddProviderInstanceDialogHeader(props: { + wizardStep: number; + wizardStepSummaries: readonly (string | null)[]; + onStepSelect: (step: number) => void; +}) { + return ( + + Add provider instance + + Configure an additional provider instance — for example, a second Codex install pointed at a + different workspace. + +
+ {WIZARD_STEPS.map((step, index) => ( + + ))} +
+
+ ); +} + +function AddProviderInstanceDialogPanel(props: { + wizardStep: number; + driver: ProviderDriverKind; + driverOption: typeof DEFAULT_DRIVER_OPTION; + driverSettingsFields: ReturnType; + label: string; + onLabelChange: (label: string) => void; + instanceId: string; + instanceIdError: string | null; + showInstanceIdError: boolean; + onInstanceIdChange: (instanceId: string) => void; + accentColor: string; + onAccentColorChange: (accentColor: string) => void; + onDriverChange: (driver: ProviderDriverKind) => void; + configDraft: Record; + onConfigDraftChange: (config: Record | undefined) => void; +}) { + return ( +
+ + + + + +
+ ); +} + +function AddProviderInstanceDriverStep(props: { + active: boolean; + driver: ProviderDriverKind; + onDriverChange: (driver: ProviderDriverKind) => void; +}) { + return ( +
+ + Driver + + props.onDriverChange(ProviderDriverKind.make(value))} + aria-labelledby="add-instance-driver-label" + className="grid grid-cols-2 gap-2.5" + > + {DRIVER_OPTIONS.map((option) => { + const IconComponent = option.icon; + const isSelected = option.value === props.driver; + return ( + + + + {option.label} + + {option.badgeLabel ? ( + + {option.badgeLabel} + + ) : null} + + ); + })} + {COMING_SOON_DRIVER_OPTIONS.map((option) => { + const IconComponent = option.icon; + return ( + + + + {option.label} + + + Coming Soon + + + ); + })} + +
+ ); +} + +function AddProviderInstanceIdentityStep(props: { + active: boolean; + driver: ProviderDriverKind; + label: string; + onLabelChange: (label: string) => void; + instanceId: string; + instanceIdError: string | null; + showInstanceIdError: boolean; + onInstanceIdChange: (instanceId: string) => void; + accentColor: string; + onAccentColorChange: (accentColor: string) => void; +}) { + return ( + <> + + + + +
+ Accent color +
+ props.onAccentColorChange(event.target.value)} + aria-label="Provider instance accent color" + className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5" + /> +
+ {PROVIDER_ACCENT_SWATCHES.map((swatch) => { + const selected = props.accentColor.toLowerCase() === swatch; + return ( - ))} -
- - -
- -
- - Driver - - setDriver(ProviderDriverKind.make(value))} - aria-labelledby="add-instance-driver-label" - className="grid grid-cols-2 gap-2.5" - > - {DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; - const isSelected = option.value === driver; - return ( - - - - {option.label} - - {option.badgeLabel ? ( - - {option.badgeLabel} - - ) : null} - - ); - })} - {COMING_SOON_DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; - return ( - - - - {option.label} - - - Coming Soon - - - ); - })} - -
- - - - - -
- Accent color -
- setAccentColor(event.target.value)} - aria-label="Provider instance accent color" - className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5" - /> -
- {PROVIDER_ACCENT_SWATCHES.map((swatch) => { - const selected = accentColor.toLowerCase() === swatch; - return ( -
- {accentColor ? ( - - ) : null} -
- - Optional marker shown in the picker. - -
- - {driverSettingsFields.length > 0 ? ( -
- -
- ) : wizardStep === 2 ? ( -
-

- This driver has no required configuration. You can add the instance now. -

-
- ) : null} -
+ ); + })}
- - + {props.accentColor ? ( - {wizardStep < wizardSteps.length - 1 ? ( - - ) : ( - - )} - + ) : null}
- -
+ + Optional marker shown in the picker. + + + + ); +} + +function AddProviderInstanceConfigStep(props: { + active: boolean; + driver: ProviderDriverKind; + driverOption: typeof DEFAULT_DRIVER_OPTION; + driverSettingsFields: ReturnType; + configDraft: Record; + onConfigDraftChange: (config: Record | undefined) => void; +}) { + if (props.driverSettingsFields.length > 0) { + return ( +
+ +
+ ); + } + + if (!props.active) { + return null; + } + + return ( +
+

+ This driver has no required configuration. You can add the instance now. +

+
+ ); +} + +function AddProviderInstanceDialogFooter(props: { + wizardStep: number; + onBack: () => void; + onNext: () => void; + onSave: () => void; +}) { + return ( + + + {props.wizardStep < WIZARD_STEPS.length - 1 ? ( + + ) : ( + + )} + ); }