From 0a7ac7c986e9e549dc1ebcfe02bced7daa3d740f Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 27 May 2026 02:38:34 +1000 Subject: [PATCH] feat(site): add AI settings providers pages and routes (#25583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 This PR was written by Coder Agents on behalf of Jake Howell. Linear: [DEVEX-355](https://linear.app/coder/issue/DEVEX-355) Fourth PR in a 5-PR stack splitting #25328. Wires the new `/ai/settings` provider management UI. - `AISettingsLayout` hosts the section under `/ai/settings` with a sidebar outlet. - `AISettingsSidebar(View)` shows a single "Providers" nav entry. The remaining sidebar entries arrive with the broader AI settings section reshuffle in the next PR. - `ProvidersPage` lists configured AI providers via the queries added in PR 2. - `AddProviderPage` walks through provider-type selection and form submission, with type-specific credential fields. - `UpdateProviderPage` edits an existing provider with the same form components. - Storybook stories cover each view's loading, empty, populated, error, and form states using the mock providers from `testHelpers/entities.ts`. - `router.tsx` mounts the new `/ai/settings` layout with index, `add`, and `:providerId` child routes. The `governance` child route lands together with the dashboard navigation changes in the next PR. Removes the now-unused knip ignore entries for `src/api/queries/aiProviders.ts` and `src/pages/AISettingsPage/ProvidersPage/components/addableProviderTypes.ts`, and drops the matching `@lintignore` tags on `getProviderIcon` and `MockAIProviders` since the pages and page stories now consume them.
Stack 1. #25579 jakehwll/DEVEX-355/01-primitives, primitives 2. #25580 jakehwll/DEVEX-355/02-api, API client and query layer 3. #25581 jakehwll/DEVEX-355/03-components, provider form components 4. **jakehwll/DEVEX-355/04-pages, pages and routes (this PR)** 5. jakehwll/DEVEX-355/05-section, section reshuffle Replaces #25328 once the stack lands.
--- site/.knip.jsonc | 8 - .../modules/management/AISettingsSidebar.tsx | 8 + .../management/AISettingsSidebarView.tsx | 16 ++ .../pages/AISettingsPage/AISettingsLayout.tsx | 21 ++ .../AddProviderPage/AddProviderPage.tsx | 49 ++++ .../AddProviderPageView.stories.tsx | 41 +++ .../AddProviderPage/AddProviderPageView.tsx | 81 ++++++ .../ProvidersPage/ProvidersPage.tsx | 28 ++ .../ProvidersPageView.stories.tsx | 63 +++++ .../ProvidersPage/ProvidersPageView.tsx | 126 +++++++++ .../UpdateProviderPage/UpdateProviderPage.tsx | 16 ++ .../UpdateProviderPageView.stories.tsx | 79 ++++++ .../UpdateProviderPageView.tsx | 248 ++++++++++++++++++ .../ProvidersPage/components/ProviderIcon.tsx | 1 - site/src/router.tsx | 28 ++ site/src/testHelpers/entities.ts | 1 - 16 files changed, 804 insertions(+), 10 deletions(-) create mode 100644 site/src/modules/management/AISettingsSidebar.tsx create mode 100644 site/src/modules/management/AISettingsSidebarView.tsx create mode 100644 site/src/pages/AISettingsPage/AISettingsLayout.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPage.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/ProvidersPage.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.stories.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPage.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx create mode 100644 site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx diff --git a/site/.knip.jsonc b/site/.knip.jsonc index d4cedd0c75..d12174a6f9 100644 --- a/site/.knip.jsonc +++ b/site/.knip.jsonc @@ -11,14 +11,6 @@ "ignore": [ "**/*Generated.ts", "src/api/chatModelOptions.ts", - // TODO(ai-settings): aiProviders.ts queries are staged in PR 2 of the - // AI settings stack; they are consumed by the provider pages in PR 4. - // Remove this exclusion once those pages land. - "src/api/queries/aiProviders.ts", - // TODO(ai-settings): addableProviderTypes.ts is staged in PR 3 of the - // AI settings stack; its exports are consumed by the provider pages - // in PR 4. Remove this exclusion once those pages land. - "src/pages/AISettingsPage/ProvidersPage/components/addableProviderTypes.ts", // TODO(devtools): debugPanelUtils.ts is staged in PR 7; its exports are // consumed by the Debug panel components in PRs 8 and 9. Remove this // exclusion once the panel components land. diff --git a/site/src/modules/management/AISettingsSidebar.tsx b/site/src/modules/management/AISettingsSidebar.tsx new file mode 100644 index 0000000000..c01bbb5285 --- /dev/null +++ b/site/src/modules/management/AISettingsSidebar.tsx @@ -0,0 +1,8 @@ +import AISettingsSidebarView from "#/modules/management/AISettingsSidebarView"; + +/** + * A sidebar for AI settings. + */ +export const AISettingsSidebar: React.FC = () => { + return ; +}; diff --git a/site/src/modules/management/AISettingsSidebarView.tsx b/site/src/modules/management/AISettingsSidebarView.tsx new file mode 100644 index 0000000000..81447b4e5c --- /dev/null +++ b/site/src/modules/management/AISettingsSidebarView.tsx @@ -0,0 +1,16 @@ +import { + Sidebar as BaseSidebar, + SettingsSidebarNavItem as SidebarNavItem, +} from "#/components/Sidebar/Sidebar"; + +const AISettingsSidebarView: React.FC = () => { + return ( + +
+ Providers +
+
+ ); +}; + +export default AISettingsSidebarView; diff --git a/site/src/pages/AISettingsPage/AISettingsLayout.tsx b/site/src/pages/AISettingsPage/AISettingsLayout.tsx new file mode 100644 index 0000000000..8a44e10352 --- /dev/null +++ b/site/src/pages/AISettingsPage/AISettingsLayout.tsx @@ -0,0 +1,21 @@ +import { Suspense } from "react"; +import { Outlet } from "react-router"; +import { Loader } from "#/components/Loader/Loader"; +import { AISettingsSidebar } from "#/modules/management/AISettingsSidebar"; + +const AISettingsLayout = () => { + return ( +
+
+ +
+ }> + + +
+
+
+ ); +}; + +export default AISettingsLayout; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPage.tsx b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPage.tsx new file mode 100644 index 0000000000..74df05f479 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPage.tsx @@ -0,0 +1,49 @@ +import { ArrowLeftIcon } from "lucide-react"; +import { Link, useSearchParams } from "react-router"; +import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert"; +import { Button } from "#/components/Button/Button"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; +import { RequirePermission } from "#/modules/permissions/RequirePermission"; +import { pageTitle } from "#/utils/page"; +import { addableProviders } from "../components/addableProviderTypes"; +import AddProviderPageView from "./AddProviderPageView"; + +const AddProviderPage: React.FC = () => { + const { permissions } = useAuthenticated(); + const hasPermission = permissions.viewAnyAIProvider; + const [searchParams] = useSearchParams(); + const typeParam = searchParams.get("type"); + + const provider = addableProviders.find((p) => p.value === typeParam); + if (!provider) { + return ( +
+ + + + + Provider type not found + + The provider type you are trying to add is not valid. Please try + again. + + +
+ ); + } + + return ( + + + {pageTitle(`New ${provider.label} Provider`, "AI Providers")} + + + + + ); +}; + +export default AddProviderPage; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx new file mode 100644 index 0000000000..f7bbab9761 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { reactRouterParameters } from "storybook-addon-remix-react-router"; +import { withToaster } from "#/testHelpers/storybook"; +import { addableProviders } from "../components/addableProviderTypes"; +import AddProviderPageView from "./AddProviderPageView"; + +const meta: Meta = { + title: "pages/AISettingsPage/AddProviderPage", + component: AddProviderPageView, + decorators: [withToaster], + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/add" }, + routing: [ + { path: "/ai/settings", useStoryElement: true }, + { path: "/ai/settings/add", useStoryElement: true }, + ], + }), + }, +}; + +export default meta; +type Story = StoryObj; + +export const AddAnthropic: Story = { + args: { + provider: addableProviders.find((p) => p.value === "anthropic")!, + }, +}; + +export const AddOpenAI: Story = { + args: { + provider: addableProviders.find((p) => p.value === "openai")!, + }, +}; + +export const AddBedrock: Story = { + args: { + provider: addableProviders.find((p) => p.value === "bedrock")!, + }, +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.tsx b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.tsx new file mode 100644 index 0000000000..6f21156077 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.tsx @@ -0,0 +1,81 @@ +import { ArrowLeftIcon } from "lucide-react"; +import { useMutation, useQueryClient } from "react-query"; +import { Link, useNavigate } from "react-router"; +import { toast } from "sonner"; +import { getErrorMessage } from "#/api/errors"; +import { createAIProviderMutation } from "#/api/queries/aiProviders"; +import { Avatar } from "#/components/Avatar/Avatar"; +import { Button } from "#/components/Button/Button"; +import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader"; +import type { AddableProvider } from "../components/addableProviderTypes"; +import { ProviderForm } from "../components/ProviderForm"; +import { getProviderIcon } from "../components/ProviderIcon"; +import { providerFormValuesToCreate } from "../components/providerFormApiMap"; + +interface AddProviderPageViewProps { + provider: AddableProvider; +} + +const AddProviderPageView: React.FC = ({ + provider, +}) => { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const createMutation = useMutation(createAIProviderMutation(queryClient)); + + return ( + <> + + + +
+
+ + {`Add a ${provider.label} provider`} +
+

+ Configure connection details and credentials. +

+
+ { + const request = providerFormValuesToCreate(values); + try { + const res = await createMutation.mutateAsync(request); + toast.success( + `Provider "${res.display_name || res.name}" added.`, + ); + // Awaited so the form's submitting state stays true through + // navigation, keeping the unsaved-changes prompt suppressed. + await navigate(`/ai/settings/${res.name}`); + } catch (error) { + const name = values.name.trim(); + toast.error( + getErrorMessage( + error, + name + ? `Failed to add provider "${name}".` + : "Failed to add provider.", + ), + ); + } + }} + /> +
+
+ + ); +}; + +export default AddProviderPageView; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPage.tsx b/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPage.tsx new file mode 100644 index 0000000000..6c08c9507e --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPage.tsx @@ -0,0 +1,28 @@ +import { useQuery } from "react-query"; +import { aiProvidersList } from "#/api/queries/aiProviders"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; +import { RequirePermission } from "#/modules/permissions/RequirePermission"; +import ProvidersPageView from "#/pages/AISettingsPage/ProvidersPage/ProvidersPageView"; +import { pageTitle } from "#/utils/page"; + +const ProvidersPage: React.FC = () => { + const { permissions } = useAuthenticated(); + const hasPermission = permissions.viewAnyAIProvider; + + const providersQuery = useQuery(aiProvidersList()); + + return ( + + {pageTitle("AI Providers")} + + + + ); +}; + +export default ProvidersPage; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.stories.tsx new file mode 100644 index 0000000000..e92a52409b --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { userEvent, within } from "storybook/test"; +import { reactRouterParameters } from "storybook-addon-remix-react-router"; +import { MockAIProviders } from "#/testHelpers/entities"; +import ProvidersPageView from "./ProvidersPageView"; + +const meta: Meta = { + title: "pages/AISettingsPage/ProvidersPageView", + component: ProvidersPageView, + args: { + isLoading: false, + isFetching: false, + error: null, + providers: MockAIProviders, + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings" }, + routing: [ + { path: "/ai/settings", useStoryElement: true }, + { path: "/ai/settings/add", useStoryElement: true }, + { path: "/ai/settings/:providerId", useStoryElement: true }, + ], + }), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Loading: Story = { + args: { + isLoading: true, + isFetching: true, + }, +}; + +export const EmptyProviders: Story = { + args: { + providers: [], + }, +}; + +export const LoadError: Story = { + args: { + isLoading: false, + isFetching: false, + error: new Error("Failed to load providers"), + providers: [], + }, +}; + +export const AddProviderDropdownOpen: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = await canvas.findByRole("button", { + name: /add provider/i, + }); + await userEvent.click(trigger); + }, +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.tsx b/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.tsx new file mode 100644 index 0000000000..dde7c8757b --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.tsx @@ -0,0 +1,126 @@ +import { ChevronDownIcon, PlusIcon } from "lucide-react"; +import { useNavigate } from "react-router"; +import type { AIProvider } from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { Button } from "#/components/Button/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "#/components/DropdownMenu/DropdownMenu"; +import { + SettingsHeader, + SettingsHeaderDescription, + SettingsHeaderTitle, +} from "#/components/SettingsHeader/SettingsHeader"; +import { + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from "#/components/Table/Table"; +import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; +import { TableLoader } from "#/components/TableLoader/TableLoader"; +import { addableProviders } from "#/pages/AISettingsPage/ProvidersPage/components/addableProviderTypes"; +import { ProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon"; +import { ProviderRow } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderRow"; + +interface ProvidersPageViewProps { + isLoading: boolean; + isFetching: boolean; + error: unknown; + providers: AIProvider[]; +} + +const AddProviderDropdown: React.FC<{ align?: "start" | "end" }> = ({ + align = "end", +}) => { + const navigate = useNavigate(); + return ( + + + + + +
+ Select a provider +
+ {addableProviders.map((entry) => ( + + void navigate( + `/ai/settings/add?type=${encodeURIComponent(entry.value)}`, + ) + } + > + + {entry.label} + + ))} +
+
+ ); +}; + +const ProvidersPageView: React.FC = ({ + isLoading, + isFetching, + error, + providers, +}) => { + const navigate = useNavigate(); + + return ( +
+ }> + Providers + + Connect third-party LLM services like OpenAI, Anthropic, or Amazon + Bedrock. Each provider supplies models that users can select for their + conversations. + + + {Boolean(error) && ( +
+ +
+ )} + + + + Name + Base URL + Status + + + + {isLoading || isFetching ? ( + + ) : providers.length === 0 ? ( + } + /> + ) : ( + providers.map((provider) => ( + navigate(`/ai/settings/${provider.name}`)} + /> + )) + )} + +
+
+ ); +}; + +export default ProvidersPageView; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPage.tsx b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPage.tsx new file mode 100644 index 0000000000..0ae990311b --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPage.tsx @@ -0,0 +1,16 @@ +import { useAuthenticated } from "#/hooks/useAuthenticated"; +import { RequirePermission } from "#/modules/permissions/RequirePermission"; +import UpdateProviderPageView from "./UpdateProviderPageView"; + +const UpdateProviderPage: React.FC = () => { + const { permissions } = useAuthenticated(); + const hasPermission = permissions.viewAnyAIProvider; + + return ( + + + + ); +}; + +export default UpdateProviderPage; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx new file mode 100644 index 0000000000..15713428f9 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen, userEvent, within } from "storybook/test"; +import { reactRouterParameters } from "storybook-addon-remix-react-router"; +import type { AIProvider } from "#/api/typesGenerated"; +import { + MockAIProviderAnthropic, + MockAIProviderBedrock, + MockAIProviderOpenAI, +} from "#/testHelpers/entities"; +import { withToaster } from "#/testHelpers/storybook"; +import UpdateProviderPageView from "./UpdateProviderPageView"; + +const routingFor = (path: string) => + reactRouterParameters({ + location: { path }, + routing: [ + { path: "/ai/settings", useStoryElement: true }, + { path: "/ai/settings/:providerId", useStoryElement: true }, + ], + }); + +const seed = (provider: AIProvider) => ({ + queries: [{ key: ["ai", "providers", provider.name], data: provider }], +}); + +const meta: Meta = { + title: "pages/AISettingsPage/UpdateProviderPageView", + component: UpdateProviderPageView, + decorators: [withToaster], +}; + +export default meta; +type Story = StoryObj; + +export const OpenAI: Story = { + parameters: { + reactRouter: routingFor(`/ai/settings/${MockAIProviderOpenAI.name}`), + ...seed(MockAIProviderOpenAI), + }, +}; + +export const Anthropic: Story = { + parameters: { + reactRouter: routingFor(`/ai/settings/${MockAIProviderAnthropic.name}`), + ...seed(MockAIProviderAnthropic), + }, +}; + +export const Bedrock: Story = { + parameters: { + reactRouter: routingFor(`/ai/settings/${MockAIProviderBedrock.name}`), + ...seed(MockAIProviderBedrock), + }, +}; + +// No seeded query: the page renders the loader while useQuery fetches. +export const Loading: Story = { + parameters: { + reactRouter: routingFor("/ai/settings/loading-provider"), + }, +}; + +export const DeleteDialogOpen: Story = { + parameters: { + reactRouter: routingFor(`/ai/settings/${MockAIProviderOpenAI.name}`), + ...seed(MockAIProviderOpenAI), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const deleteButton = await canvas.findByRole("button", { + name: /^delete$/i, + }); + await userEvent.click(deleteButton); + // DeleteDialog renders via Radix portal, so search the document, not + // just the story canvas. + await expect(await screen.findByRole("dialog")).toBeInTheDocument(); + await expect(await screen.findByText(/irreversible/i)).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx new file mode 100644 index 0000000000..dbeb16003d --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx @@ -0,0 +1,248 @@ +import { isAxiosError } from "axios"; +import { ArrowLeftIcon } from "lucide-react"; +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { Link, Navigate, useNavigate, useParams } from "react-router"; +import { toast } from "sonner"; +import { getErrorMessage } from "#/api/errors"; +import { + aiProvider, + deleteAIProviderMutation, + updateAIProviderMutation, +} from "#/api/queries/aiProviders"; +import { Avatar } from "#/components/Avatar/Avatar"; +import { Badge } from "#/components/Badge/Badge"; +import { Button } from "#/components/Button/Button"; +import { DeleteDialog } from "#/components/Dialogs/DeleteDialog/DeleteDialog"; +import { Loader } from "#/components/Loader/Loader"; +import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader"; +import { Switch } from "#/components/Switch/Switch"; +import { pageTitle } from "#/utils/page"; +import { ProviderForm } from "../components/ProviderForm"; +import { getProviderIcon } from "../components/ProviderIcon"; +import { + aiProviderToFormValues, + getProviderDisplayType, + hasBedrockStoredCredentials, + isBedrockProvider, + providerFormValuesToUpdate, +} from "../components/providerFormApiMap"; + +const BACK_HREF = "/ai/settings"; + +const UpdateProviderPageView: React.FC = () => { + const { providerId } = useParams<{ providerId: string }>(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const providerQuery = useQuery({ + ...aiProvider(providerId ?? ""), + enabled: Boolean(providerId), + }); + + const provider = providerQuery.data; + const providerIsOpenAiAnthropic = + provider !== undefined && !isBedrockProvider(provider); + + const updateMutation = useMutation( + updateAIProviderMutation(queryClient, providerId ?? ""), + ); + + const deleteMutation = useMutation( + deleteAIProviderMutation(queryClient, providerId ?? ""), + ); + + // Rendered into every non-redirect return so the document title reflects + // the provider as soon as we know it; falls back to a placeholder while + // the query is in flight. + const title = ( + + {pageTitle( + (provider?.display_name || provider?.name) ?? "Loading...", + "AI Providers", + )} + + ); + + if (!providerId) { + return ; + } + + if (providerQuery.isLoading) { + return ( + <> + {title} + + + ); + } + + if (providerQuery.isError) { + const status = isAxiosError(providerQuery.error) + ? providerQuery.error.response?.status + : undefined; + if (status === 404) { + return ; + } + return ( + <> + {title} +
+

+ {getErrorMessage(providerQuery.error, "Failed to load provider.")} +

+ + + +
+ + ); + } + + if (!provider) { + return ; + } + + const openAiAnthropicSavedApiKey = + providerIsOpenAiAnthropic && provider.api_keys.length > 0; + const openAiAnthropicMaskedApiKey = providerIsOpenAiAnthropic + ? provider.api_keys[0]?.masked + : undefined; + + return ( + <> + {title} +
+ + + + +
+
+
+ + + + {provider.display_name || provider.name} + + + {!provider.enabled && Disabled} +
+
+

+ Add or update models for this provider.{" "} + + Model settings + +

+
+ { + updateMutation.mutate( + { enabled: checked }, + { + onSuccess: (updated) => { + toast.success( + `Provider "${updated.display_name || updated.name}" ${checked ? "enabled" : "disabled"}.`, + ); + }, + }, + ); + }} + disabled={updateMutation.isPending} + aria-label="Provider enabled" + /> + Enable +
+
+
+ { + const request = providerFormValuesToUpdate(values, provider); + try { + const updated = await updateMutation.mutateAsync(request); + toast.success( + `Provider "${updated.display_name || updated.name}" updated.`, + ); + } catch (error) { + toast.error( + getErrorMessage( + error, + `Failed to update provider "${provider.display_name || provider.name}".`, + ), + ); + } + }} + /> +
+ { + setDeleteDialogOpen(false); + }} + onConfirm={() => { + deleteMutation.mutate(undefined, { + onSuccess: () => { + toast.success( + `Provider "${provider.display_name || provider.name}" deleted.`, + ); + setDeleteDialogOpen(false); + void navigate(BACK_HREF, { replace: true }); + }, + onError: (error) => { + toast.error( + getErrorMessage( + error, + `Failed to delete provider "${provider.display_name || provider.name}".`, + ), + ); + }, + }); + }} + /> +
+ + ); +}; + +export default UpdateProviderPageView; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.tsx index feaf29f898..f84dcc8375 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.tsx @@ -5,7 +5,6 @@ type ProviderIconProps = { provider: string; }; -/** @lintignore Consumed by provider pages landing in the next PR of the AI settings stack. */ export const getProviderIcon = (provider: string): string | undefined => { switch (provider) { case "openai": diff --git a/site/src/router.tsx b/site/src/router.tsx index e84bec537e..6a7b04d151 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -429,6 +429,25 @@ const AIBridgeSessionThreadsPage = lazy( () => import("./pages/AIBridgePage/SessionThreadsPage/SessionThreadsPage"), ); +const AISettingsLayout = lazy( + () => import("./pages/AISettingsPage/AISettingsLayout"), +); +const AISettingsProvidersPage = lazy( + () => import("./pages/AISettingsPage/ProvidersPage/ProvidersPage"), +); +const AISettingsUpdateProviderPage = lazy( + () => + import( + "./pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPage" + ), +); +const AISettingsAddProviderPage = lazy( + () => + import( + "./pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPage" + ), +); + const GlobalLayout = () => { return ( }> @@ -678,6 +697,15 @@ export const router = createBrowserRouter( } /> + }> + } /> + } /> + } + /> + + }> } /> } /> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index f15d839e14..c8b8a083ba 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -5574,7 +5574,6 @@ export const MockAIProviderBedrock: TypesGen.AIProvider = { updated_at: "2026-05-14T10:00:00Z", }; -/** @lintignore Consumed by page stories landing in PR 4 of the AI settings stack. */ export const MockAIProviders: TypesGen.AIProvider[] = [ MockAIProviderOpenAI, MockAIProviderAnthropic,