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
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export type WizardNavigation =
| { readonly kind: "navigate"; readonly step: number }
| { readonly kind: "blocked"; readonly step: number; readonly error: string };

const IDENTITY_STEP = 1;

export const ADD_PROVIDER_WIZARD_STEPS = ["Driver", "Identity", "Config"] as const;

/**
* Resolve navigation within the add-provider wizard.
*
* Moving forward past Identity requires a valid instance id, whether the user
* advances one step at a time or skips directly to Config from a step header.
* A blocked skip lands on Identity so its existing inline validation is
* visible. Backward navigation is always preserved.
*/
export function resolveWizardNavigation(
currentStep: number,
requestedStep: number,
stepCount: number,
validation: { readonly instanceIdError: string | null },
): WizardNavigation {
const lastStep = Math.max(0, stepCount - 1);
const targetStep = Math.max(0, Math.min(lastStep, requestedStep));
const movesForwardPastIdentity = currentStep <= IDENTITY_STEP && targetStep > IDENTITY_STEP;

if (movesForwardPastIdentity && validation.instanceIdError !== null) {
return {
kind: "blocked",
step: Math.min(IDENTITY_STEP, lastStep),
error: validation.instanceIdError,
};
}

return { kind: "navigate", step: targetStep };
}
44 changes: 44 additions & 0 deletions apps/web/src/components/settings/AddProviderInstanceDialog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vite-plus/test";

import { resolveWizardNavigation } from "./AddProviderInstanceDialog.logic";

describe("resolveWizardNavigation", () => {
const invalidId = { instanceIdError: "Instance ID is required." };
const validId = { instanceIdError: null };

it("allows moving from Driver to Identity before the instance id is valid", () => {
expect(resolveWizardNavigation(0, 1, 3, invalidId)).toEqual({ kind: "navigate", step: 1 });
});

it("blocks Next from Identity to Config while the instance id is invalid", () => {
expect(resolveWizardNavigation(1, 2, 3, invalidId)).toEqual({
kind: "blocked",
step: 1,
error: "Instance ID is required.",
});
});

it("stops a direct Driver-to-Config skip at Identity and surfaces its error", () => {
expect(resolveWizardNavigation(0, 2, 3, invalidId)).toEqual({
kind: "blocked",
step: 1,
error: "Instance ID is required.",
});
});

it("allows advancing and skipping forward once the instance id is valid", () => {
expect(resolveWizardNavigation(1, 2, 3, validId)).toEqual({ kind: "navigate", step: 2 });
expect(resolveWizardNavigation(0, 2, 3, validId)).toEqual({ kind: "navigate", step: 2 });
});

it("always preserves backward Driver and Identity navigation", () => {
expect(resolveWizardNavigation(2, 1, 3, invalidId)).toEqual({ kind: "navigate", step: 1 });
expect(resolveWizardNavigation(2, 0, 3, invalidId)).toEqual({ kind: "navigate", step: 0 });
expect(resolveWizardNavigation(1, 0, 3, invalidId)).toEqual({ kind: "navigate", step: 0 });
});

it("clamps requested steps to the wizard bounds", () => {
expect(resolveWizardNavigation(2, 8, 3, validId)).toEqual({ kind: "navigate", step: 2 });
expect(resolveWizardNavigation(0, -1, 3, invalidId)).toEqual({ kind: "navigate", step: 0 });
});
});
115 changes: 43 additions & 72 deletions apps/web/src/components/settings/AddProviderInstanceDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
"use client";

import { CheckIcon } from "lucide-react";
import { Radio as RadioPrimitive } from "@base-ui/react/radio";
import { useCallback, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import {
ProviderInstanceId,
ProviderDriverKind,
Expand All @@ -29,6 +28,12 @@ import { toastManager } from "../ui/toast";
import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta";
import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm";
import { AnimatedHeight } from "../AnimatedHeight";
import {
ADD_PROVIDER_WIZARD_STEPS,
resolveWizardNavigation,
type WizardNavigation,
} from "./AddProviderInstanceDialog.logic";
import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps";

const PROVIDER_ACCENT_SWATCHES = [
"#2563eb",
Expand Down Expand Up @@ -143,26 +148,37 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
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<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],
);
const setConfigDraft = (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;
});
};

const applyWizardNavigation = (navigation: WizardNavigation) => {
if (navigation.kind === "blocked") {
setHasAttemptedSubmit(true);
}
setWizardStep(navigation.step);
};

const handleSave = useCallback(() => {
const navigateToStep = (requestedStep: number) => {
applyWizardNavigation(
resolveWizardNavigation(wizardStep, requestedStep, ADD_PROVIDER_WIZARD_STEPS.length, {
instanceIdError,
}),
);
};

const handleSave = () => {
setHasAttemptedSubmit(true);
if (instanceIdError !== null) return;

Expand Down Expand Up @@ -201,18 +217,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
description: error instanceof Error ? error.message : "Update failed.",
});
}
}, [
driver,
driverOption,
configByDriver,
instanceId,
instanceIdError,
label,
accentColor,
onOpenChange,
settings.providerInstances,
updateSettings,
]);
};

return (
<Dialog open={open} onOpenChange={onOpenChange}>
Expand All @@ -224,46 +229,12 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
Configure an additional provider instance — for example, a second Codex install
pointed at a different workspace.
</DialogDescription>
<div className="grid grid-cols-3 gap-2">
{wizardSteps.map((step, index) => (
<button
key={step}
type="button"
className={cn(
"grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 rounded-lg border px-3 py-2 text-left",
index === wizardStep
? "border-primary bg-primary/10 ring-1 ring-primary/25"
: index < wizardStep
? "border-border bg-background"
: "border-border bg-muted/40",
)}
onClick={() => setWizardStep(index)}
>
<span
className={cn(
"row-span-2 mt-0.5 grid size-4 place-items-center rounded-full border",
index < wizardStep
? "border-primary bg-primary text-primary-foreground"
: index === wizardStep
? "border-primary bg-background"
: "border-muted-foreground/35 bg-background",
)}
aria-hidden
>
{index < wizardStep ? <CheckIcon className="size-3" /> : null}
</span>
<span className="text-[10px] font-medium uppercase text-muted-foreground">
Step {index + 1}
</span>
<span className="truncate text-xs font-semibold text-foreground">
{step}
{index < wizardStep && wizardStepSummaries[index]
? `: ${wizardStepSummaries[index]}`
: ""}
</span>
</button>
))}
</div>
<AddProviderInstanceWizardSteps
currentStep={wizardStep}
summaries={wizardStepSummaries}
instanceIdError={instanceIdError}
onNavigation={applyWizardNavigation}
/>
</DialogHeader>

<div
Expand Down Expand Up @@ -452,8 +423,8 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
>
{wizardStep === 0 ? "Cancel" : "Back"}
</Button>
{wizardStep < wizardSteps.length - 1 ? (
<Button size="sm" onClick={() => setWizardStep((step) => Math.min(2, step + 1))}>
{wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? (
<Button size="sm" onClick={() => navigateToStep(wizardStep + 1)}>
Next
</Button>
) : (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Children, isValidElement, type ReactElement } from "react";
import { describe, expect, it, vi } from "vite-plus/test";

import { ADD_PROVIDER_WIZARD_STEPS } from "./AddProviderInstanceDialog.logic";
import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps";

interface StepButtonProps {
readonly "aria-current"?: string;
readonly onClick: () => void;
}

function renderStepButtons(
currentStep: number,
instanceIdError: string | null,
onNavigation: Parameters<typeof AddProviderInstanceWizardSteps>[0]["onNavigation"],
): ReactElement<StepButtonProps>[] {
const header = AddProviderInstanceWizardSteps({
currentStep,
summaries: ["Codex", "Codex Workspace", null],
instanceIdError,
onNavigation,
});

return Children.toArray(header.props.children).filter(
(child): child is ReactElement<StepButtonProps> => isValidElement(child),
);
}

describe("AddProviderInstanceWizardSteps", () => {
it("gates the actual Config header click through Identity validation", () => {
const onNavigation = vi.fn();
const buttons = renderStepButtons(0, "Instance ID is required.", onNavigation);

expect(buttons).toHaveLength(ADD_PROVIDER_WIZARD_STEPS.length);
buttons[2]!.props.onClick();

expect(onNavigation).toHaveBeenCalledOnce();
expect(onNavigation).toHaveBeenCalledWith({
kind: "blocked",
step: 1,
error: "Instance ID is required.",
});
});

it("marks the wizard step separately from the clicked button focus", () => {
const buttons = renderStepButtons(1, "Instance ID is required.", vi.fn());

expect(buttons[0]!.props["aria-current"]).toBeUndefined();
expect(buttons[1]!.props["aria-current"]).toBe("step");
expect(buttons[2]!.props["aria-current"]).toBeUndefined();
});

it("preserves the actual backward header click", () => {
const onNavigation = vi.fn();
const buttons = renderStepButtons(2, "Instance ID is required.", onNavigation);

buttons[0]!.props.onClick();

expect(onNavigation).toHaveBeenCalledWith({ kind: "navigate", step: 0 });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { CheckIcon } from "lucide-react";

import { cn } from "../../lib/utils";
import {
ADD_PROVIDER_WIZARD_STEPS,
resolveWizardNavigation,
type WizardNavigation,
} from "./AddProviderInstanceDialog.logic";

interface AddProviderInstanceWizardStepsProps {
readonly currentStep: number;
readonly summaries: readonly (string | null)[];
readonly instanceIdError: string | null;
readonly onNavigation: (navigation: WizardNavigation) => void;
}

export function AddProviderInstanceWizardSteps({
currentStep,
summaries,
instanceIdError,
onNavigation,
}: AddProviderInstanceWizardStepsProps) {
return (
<div className="grid grid-cols-3 gap-2">
{ADD_PROVIDER_WIZARD_STEPS.map((step, index) => (
<button
key={step}
type="button"
className={cn(
"grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 rounded-lg border px-3 py-2 text-left",
index === currentStep
? "border-primary bg-primary/10 ring-1 ring-primary/25"
: index < currentStep
? "border-border bg-background"
: "border-border bg-muted/40",
)}
aria-current={index === currentStep ? "step" : undefined}
onClick={() =>
onNavigation(
resolveWizardNavigation(currentStep, index, ADD_PROVIDER_WIZARD_STEPS.length, {
instanceIdError,
}),
)
}
>
<span
className={cn(
"row-span-2 mt-0.5 grid size-4 place-items-center rounded-full border",
index < currentStep
? "border-primary bg-primary text-primary-foreground"
: index === currentStep
? "border-primary bg-background"
: "border-muted-foreground/35 bg-background",
)}
aria-hidden
>
{index < currentStep ? <CheckIcon className="size-3" /> : null}
</span>
<span className="text-[10px] font-medium uppercase text-muted-foreground">
Step {index + 1}
</span>
<span className="truncate text-xs font-semibold text-foreground">
{step}
{index < currentStep && summaries[index] ? `: ${summaries[index]}` : ""}
</span>
</button>
))}
</div>
);
}
Loading