diff --git a/site/AGENTS.md b/site/AGENTS.md index 3f41d17150..872a020a29 100644 --- a/site/AGENTS.md +++ b/site/AGENTS.md @@ -38,6 +38,13 @@ When investigating or editing TypeScript/React code, always use the TypeScript l (Table, Badge, icons, error handlers) and sibling files for local helpers. Duplicating existing components wastes effort and creates maintenance burden. +- **Modifying core components is a cross-cutting change.** Treat new + exports or visual changes in `site/src/components/` differently from + feature-folder edits. They affect every consumer across the site, so + coordinate with design before extending them. When you need a small + variant of a shared primitive (for example, a separator with + feature-specific styling), define it locally in your feature folder + first and graduate it later if a shared design lands. - Keep component files under ~500 lines. When a file grows beyond that, extract logical sections into sub-components or a folder with an index file. @@ -80,6 +87,18 @@ When investigating or editing TypeScript/React code, always use the TypeScript l - Do not use emdash (U+2014), endash (U+2013), or ` -- ` as punctuation in code, comments, string literals, or documentation. Use commas, semicolons, or periods instead. Restructure the sentence if needed. +- **Avoid unnecessary indirection.** Inline single-use module-level + constants, single-use aliases, and one-line helpers that just return a + single field at the call site. Do not create wrapper hooks that only + delegate to a library hook plus a couple of derived booleans. Inline + the call at each site instead. Indirection should pay for itself with + shared usage or non-trivial logic; otherwise it adds a layer reviewers + have to navigate without explaining anything. +- **Re-evaluate helpers after upstream refactors.** When you change how + a value is computed (for example, by moving fallback logic into the + builder), check whether existing helpers that consumed that value have + collapsed to a pass-through. If a helper now just returns a single + field, delete it and inline the field access at the call sites. ## TypeScript Type Safety diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 48b1f7910d..db0b637682 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3281,6 +3281,24 @@ class ExperimentalApiMethods { ); }; + getChatPersonalModelOverridesAdminSettings = + async (): Promise => { + const response = + await this.axios.get( + "/api/experimental/chats/config/personal-model-overrides", + ); + return response.data; + }; + + updateChatPersonalModelOverridesAdminSettings = async ( + req: TypesGen.UpdateChatPersonalModelOverridesAdminSettingsRequest, + ): Promise => { + await this.axios.put( + "/api/experimental/chats/config/personal-model-overrides", + req, + ); + }; + getChatDebugLogging = async (): Promise => { const response = @@ -3314,6 +3332,25 @@ class ExperimentalApiMethods { ); }; + getUserChatPersonalModelOverrides = + async (): Promise => { + const response = + await this.axios.get( + "/api/experimental/chats/config/user-personal-model-overrides", + ); + return response.data; + }; + + updateUserChatPersonalModelOverride = async ( + context: TypesGen.ChatPersonalModelOverrideContext, + req: TypesGen.UpdateUserChatPersonalModelOverrideRequest, + ): Promise => { + await this.axios.put( + `/api/experimental/chats/config/user-personal-model-overrides/${encodeURIComponent(context)}`, + req, + ); + }; + getChatDebugRuns = async ( chatId: string, ): Promise => { diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index e61a15afe8..2131576f3d 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -1329,6 +1329,32 @@ export const updateChatDesktopEnabled = (queryClient: QueryClient) => ({ }, }); +const chatPersonalModelOverridesAdminSettingsKey = [ + ...chatsKey, + "admin-personal-model-overrides", +] as const; + +export const chatPersonalModelOverridesAdminSettings = () => ({ + queryKey: chatPersonalModelOverridesAdminSettingsKey, + queryFn: () => API.experimental.getChatPersonalModelOverridesAdminSettings(), +}); + +export const updateChatPersonalModelOverridesAdminSettings = ( + queryClient: QueryClient, +) => ({ + mutationFn: ( + req: TypesGen.UpdateChatPersonalModelOverridesAdminSettingsRequest, + ) => API.experimental.updateChatPersonalModelOverridesAdminSettings(req), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: chatPersonalModelOverridesAdminSettingsKey, + }); + await queryClient.invalidateQueries({ + queryKey: userChatPersonalModelOverridesKey, + }); + }, +}); + export * from "./chatDebugLogging"; export const chatAdvisorConfigKey = ["chat-advisor-config"] as const; @@ -1444,6 +1470,34 @@ export const updateUserChatCustomPrompt = (queryClient: QueryClient) => ({ }, }); +const userChatPersonalModelOverridesKey = [ + ...chatsKey, + "user-personal-model-overrides", +] as const; + +export const userChatPersonalModelOverrides = () => ({ + queryKey: userChatPersonalModelOverridesKey, + queryFn: (): Promise => + API.experimental.getUserChatPersonalModelOverrides(), +}); + +type UpdateUserChatPersonalModelOverrideArgs = { + context: TypesGen.ChatPersonalModelOverrideContext; + req: TypesGen.UpdateUserChatPersonalModelOverrideRequest; +}; + +export const updateUserChatPersonalModelOverride = ( + queryClient: QueryClient, +) => ({ + mutationFn: ({ context, req }: UpdateUserChatPersonalModelOverrideArgs) => + API.experimental.updateUserChatPersonalModelOverride(context, req), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: userChatPersonalModelOverridesKey, + }); + }, +}); + const userCompactionThresholdsKey = [ "chat-user-compaction-thresholds", ] as const; diff --git a/site/src/pages/AgentsPage/AgentCreatePage.tsx b/site/src/pages/AgentsPage/AgentCreatePage.tsx index 210e3d0978..1bdd01d810 100644 --- a/site/src/pages/AgentsPage/AgentCreatePage.tsx +++ b/site/src/pages/AgentsPage/AgentCreatePage.tsx @@ -8,6 +8,7 @@ import { chatModels, createChat, mcpServerConfigs, + userChatPersonalModelOverrides, } from "#/api/queries/chats"; import { workspaces } from "#/api/queries/workspaces"; import type * as TypesGen from "#/api/typesGenerated"; @@ -25,7 +26,6 @@ import { getModelOptionsFromConfigs } from "./utils/modelOptions"; import { buildAgentChatPath } from "./utils/navigation"; const lastModelConfigIDStorageKey = "agents.last-model-config-id"; -const nilUUID = "00000000-0000-0000-0000-000000000000"; const AgentCreatePage: FC = () => { const queryClient = useQueryClient(); @@ -34,6 +34,9 @@ const AgentCreatePage: FC = () => { const chatModelsQuery = useQuery(chatModels()); const chatModelConfigsQuery = useQuery(chatModelConfigs()); + const personalModelOverridesQuery = useQuery( + userChatPersonalModelOverrides(), + ); const mcpServersQuery = useQuery(mcpServerConfigs()); const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 })); const createMutation = useMutation(createChat(queryClient)); @@ -54,7 +57,6 @@ const AgentCreatePage: FC = () => { organizationId, planMode, }: CreateChatOptions) => { - const modelConfigID = model || nilUUID; const content: TypesGen.ChatInputPart[] = []; if (message.trim()) { content.push({ type: "text", text: message }); @@ -64,25 +66,28 @@ const AgentCreatePage: FC = () => { content.push({ type: "file", file_id: fileID }); } } - const createdChat = await createMutation.mutateAsync({ + const createRequest: TypesGen.CreateChatRequest = { organization_id: organizationId, content, workspace_id: workspaceId, - model_config_id: modelConfigID, mcp_server_ids: mcpServerIds && mcpServerIds.length > 0 ? mcpServerIds : undefined, plan_mode: planMode === "plan" ? "plan" : undefined, client_type: "ui", - }); + ...(model ? { model_config_id: model } : {}), + }; + const createdChat = await createMutation.mutateAsync(createRequest); - if (modelConfigID !== nilUUID) { - localStorage.setItem(lastModelConfigIDStorageKey, modelConfigID); - } else { - localStorage.removeItem(lastModelConfigIDStorageKey); + if (model) { + localStorage.setItem(lastModelConfigIDStorageKey, model); } navigate(buildAgentChatPath({ chatId: createdChat.id })); }; + const rootPersonalModelOverride = personalModelOverridesQuery.data?.enabled + ? personalModelOverridesQuery.data.root + : undefined; + const handleChimeToggle = () => { const next = !chimeEnabled; setChimeEnabledState(next); @@ -123,6 +128,8 @@ const AgentCreatePage: FC = () => { modelConfigs={chatModelConfigsQuery.data ?? []} isModelCatalogLoading={chatModelsQuery.isLoading} isModelConfigsLoading={chatModelConfigsQuery.isLoading} + rootPersonalModelOverride={rootPersonalModelOverride} + isPersonalModelOverridesLoading={personalModelOverridesQuery.isLoading} mcpServers={mcpServersQuery.data ?? []} onMCPAuthComplete={() => void mcpServersQuery.refetch()} workspaceCount={workspacesQuery.data?.count} diff --git a/site/src/pages/AgentsPage/AgentSettingsAgentsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsAgentsPage.tsx index 59d13ce8ce..5f664afb15 100644 --- a/site/src/pages/AgentsPage/AgentSettingsAgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsAgentsPage.tsx @@ -6,7 +6,11 @@ import { useQueryClient, } from "react-query"; import { API } from "#/api/api"; -import { chatModelConfigs } from "#/api/queries/chats"; +import { + chatModelConfigs, + chatPersonalModelOverridesAdminSettings, + updateChatPersonalModelOverridesAdminSettings, +} from "#/api/queries/chats"; import type * as TypesGen from "#/api/typesGenerated"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; @@ -46,6 +50,10 @@ const AgentSettingsAgentsPage: FC = () => { const queryClient = useQueryClient(); const canEditDeploymentConfig = permissions.editDeploymentConfig; + const personalModelOverridesAdminSettingsQuery = useQuery({ + ...chatPersonalModelOverridesAdminSettings(), + enabled: canEditDeploymentConfig, + }); const generalModelOverrideQuery = useQuery({ ...chatModelOverrideQuery(generalOverrideContext), enabled: canEditDeploymentConfig, @@ -59,6 +67,9 @@ const AgentSettingsAgentsPage: FC = () => { enabled: canEditDeploymentConfig, }); const modelConfigsQuery = useQuery(chatModelConfigs()); + const savePersonalModelOverridesAdminSettingsMutation = useMutation( + updateChatPersonalModelOverridesAdminSettings(queryClient), + ); const saveGeneralModelOverrideMutation = useMutation( updateChatModelOverrideMutation(queryClient, generalOverrideContext), ); @@ -75,6 +86,23 @@ const AgentSettingsAgentsPage: FC = () => { return ( { + void personalModelOverridesAdminSettingsQuery.refetch(); + }} + isRetryingAdminOverrides={ + personalModelOverridesAdminSettingsQuery.isFetching + } + onSaveAdminOverrides={ + savePersonalModelOverridesAdminSettingsMutation.mutate + } + isSavingAdminOverrides={ + savePersonalModelOverridesAdminSettingsMutation.isPending + } + isSaveAdminOverridesError={ + savePersonalModelOverridesAdminSettingsMutation.isError + } generalModelOverrideData={generalModelOverrideQuery.data} titleGenerationModelOverrideData={titleGenerationModelQuery.data} exploreModelOverrideData={exploreModelOverrideQuery.data} diff --git a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx index 457cd2a9c8..91433dcd0d 100644 --- a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx @@ -109,6 +109,13 @@ const allModelConfigs: TypesGen.ChatModelConfig[] = [ const makeArgs = ( overrides: Partial = {}, ): AgentSettingsAgentsPageViewProps => ({ + adminOverridesData: { allow_users: false }, + adminOverridesError: undefined, + onRetryAdminOverrides: fn(), + isRetryingAdminOverrides: false, + onSaveAdminOverrides: fn(), + isSavingAdminOverrides: false, + isSaveAdminOverridesError: false, generalModelOverrideData: buildOverrideData("general"), titleGenerationModelOverrideData: buildTitleGenerationModelOverrideData(), exploreModelOverrideData: buildOverrideData("explore"), @@ -173,6 +180,7 @@ export const AllOverridesUnset: Story = { const headings = await canvas.findAllByRole("heading", { level: 3 }); expect(headings.map((heading) => heading.textContent?.trim())).toEqual([ + "Enable users to define their personal overrides", "General model", "Title generation model", "Explore subagent model", @@ -204,6 +212,51 @@ export const AllOverridesUnset: Story = { }, }; +export const PersonalOverridesDisabled: Story = { + args: makeArgs({ + adminOverridesData: { allow_users: false }, + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable users to define their personal overrides", + }); + + expect(toggle).not.toBeChecked(); + }, +}; + +export const PersonalOverridesEnabled: Story = { + args: makeArgs({ + adminOverridesData: { allow_users: true }, + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable users to define their personal overrides", + }); + + expect(toggle).toBeChecked(); + }, +}; + +export const PersonalOverridesLoadError: Story = { + args: makeArgs({ + adminOverridesData: undefined, + adminOverridesError: new Error("Failed to load personal model overrides."), + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect( + await canvas.findByText("Failed to load personal model overrides."), + ).toBeInTheDocument(); + expect( + canvas.queryByText("Loading personal model override settings..."), + ).not.toBeInTheDocument(); + }, +}; + export const EachOverrideSetToEnabledModel: Story = { args: makeArgs({ generalModelOverrideData: buildOverrideData("general", { diff --git a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx index af332b78ed..46e2300975 100644 --- a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx @@ -1,5 +1,9 @@ import type { FC } from "react"; import type * as TypesGen from "#/api/typesGenerated"; +import { + AdminPersonalModelOverridesSettings, + type SavePersonalModelOverridesAdminSetting, +} from "./components/AdminPersonalModelOverridesSettings"; import { SectionHeader } from "./components/SectionHeader"; import { type MutationCallbacks, @@ -12,6 +16,13 @@ type SaveModelOverride = ( ) => void; export interface AgentSettingsAgentsPageViewProps { + adminOverridesData?: TypesGen.ChatPersonalModelOverridesAdminSettings; + adminOverridesError?: unknown; + onRetryAdminOverrides?: () => void; + isRetryingAdminOverrides?: boolean; + onSaveAdminOverrides: SavePersonalModelOverridesAdminSetting; + isSavingAdminOverrides: boolean; + isSaveAdminOverridesError: boolean; generalModelOverrideData?: TypesGen.ChatModelOverrideResponse; titleGenerationModelOverrideData?: TypesGen.ChatModelOverrideResponse; exploreModelOverrideData?: TypesGen.ChatModelOverrideResponse; @@ -32,6 +43,13 @@ export interface AgentSettingsAgentsPageViewProps { export const AgentSettingsAgentsPageView: FC< AgentSettingsAgentsPageViewProps > = ({ + adminOverridesData, + adminOverridesError, + onRetryAdminOverrides, + isRetryingAdminOverrides, + onSaveAdminOverrides, + isSavingAdminOverrides, + isSaveAdminOverridesError, generalModelOverrideData, titleGenerationModelOverrideData, exploreModelOverrideData, @@ -63,6 +81,15 @@ export const AgentSettingsAgentsPageView: FC< label="Agents" description="Configure defaults for delegated agents and other agent-specific capabilities." /> + {showGeneralModelSection && onSaveGeneralModelOverride && (
{ + const queryClient = useQueryClient(); + const overridesQuery = useQuery(userChatPersonalModelOverrides()); + const chatModelsQuery = useQuery(chatModels()); + const modelConfigsQuery = useQuery(chatModelConfigs()); + const saveRootModelOverrideMutation = useMutation( + updateUserChatPersonalModelOverride(queryClient), + ); + const saveGeneralModelOverrideMutation = useMutation( + updateUserChatPersonalModelOverride(queryClient), + ); + const saveExploreModelOverrideMutation = useMutation( + updateUserChatPersonalModelOverride(queryClient), + ); + const modelOptions = getModelOptionsFromConfigs( + modelConfigsQuery.data, + chatModelsQuery.data, + ); + const modelConfigsError = modelConfigsQuery.error ?? chatModelsQuery.error; + const isLoadingModels = + chatModelsQuery.isLoading || modelConfigsQuery.isLoading; + + const saveModelOverride = ( + context: TypesGen.ChatPersonalModelOverrideContext, + mutation: typeof saveRootModelOverrideMutation, + ) => { + return ( + req: TypesGen.UpdateUserChatPersonalModelOverrideRequest, + options?: { onSuccess?: () => void; onError?: () => void }, + ) => { + mutation.mutate({ context, req }, options); + }; + }; + + return ( + { + void overridesQuery.refetch(); + }} + isRetryingOverrides={overridesQuery.isFetching} + isLoadingOverrides={overridesQuery.isLoading} + modelOptions={modelOptions} + modelConfigs={modelConfigsQuery.data ?? []} + modelConfigsError={modelConfigsError} + isLoadingModels={isLoadingModels} + onSaveRootModelOverride={saveModelOverride( + "root", + saveRootModelOverrideMutation, + )} + isSavingRootModelOverride={saveRootModelOverrideMutation.isPending} + isSaveRootModelOverrideError={saveRootModelOverrideMutation.isError} + onSaveGeneralModelOverride={saveModelOverride( + "general", + saveGeneralModelOverrideMutation, + )} + isSavingGeneralModelOverride={saveGeneralModelOverrideMutation.isPending} + isSaveGeneralModelOverrideError={saveGeneralModelOverrideMutation.isError} + onSaveExploreModelOverride={saveModelOverride( + "explore", + saveExploreModelOverrideMutation, + )} + isSavingExploreModelOverride={saveExploreModelOverrideMutation.isPending} + isSaveExploreModelOverrideError={saveExploreModelOverrideMutation.isError} + /> + ); +}; + +export default AgentSettingsUserAgentsPage; diff --git a/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.stories.tsx new file mode 100644 index 0000000000..ba0460231f --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.stories.tsx @@ -0,0 +1,627 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +import type * as TypesGen from "#/api/typesGenerated"; +import { + AgentSettingsUserAgentsPageView, + type AgentSettingsUserAgentsPageViewProps, +} from "./AgentSettingsUserAgentsPageView"; +import type { ModelSelectorOption } from "./components/ChatElements"; + +const MALFORMED_WARNING = + "The saved override is malformed. Choose a valid value and save to replace it."; +const UNAVAILABLE_WARNING = + "The saved model is unavailable and will be ignored until you choose a valid model override."; + +const buildModelConfig = ( + overrides: Partial = {}, +): TypesGen.ChatModelConfig => ({ + id: "model-default", + provider: "openai", + model: "gpt-4.1-mini", + display_name: "GPT 4.1 Mini", + enabled: true, + is_default: false, + context_limit: 1_000_000, + compression_threshold: 70, + created_at: "2026-03-12T12:00:00.000Z", + updated_at: "2026-03-12T12:00:00.000Z", + ...overrides, +}); + +const buildOverride = ( + context: TypesGen.ChatPersonalModelOverrideContext, + overrides: Partial = {}, +): TypesGen.ChatPersonalModelOverride => ({ + context, + mode: context === "root" ? "chat_default" : "deployment_default", + model_config_id: "", + is_set: false, + is_malformed: false, + ...overrides, +}); + +const buildDeploymentDefault = ( + context: TypesGen.ChatModelOverrideContext, + overrides: Partial = {}, +): TypesGen.ChatModelOverrideResponse => ({ + context, + model_config_id: "", + is_malformed: false, + ...overrides, +}); + +const buildDeploymentDefaults = ( + overrides: Partial = {}, +): TypesGen.ChatPersonalModelOverrideDeploymentDefaults => ({ + general: buildDeploymentDefault("general"), + explore: buildDeploymentDefault("explore"), + ...overrides, +}); + +const defaultModelConfig = buildModelConfig({ + id: "model-gpt-4.1-mini", + display_name: "GPT 4.1 Mini", + is_default: true, +}); + +const claudeModelConfig = buildModelConfig({ + id: "model-claude-sonnet-4", + provider: "anthropic", + model: "claude-sonnet-4", + display_name: "Claude Sonnet 4", + context_limit: 200_000, +}); + +const disabledModelConfig = buildModelConfig({ + id: "model-disabled", + model: "gpt-4.1-legacy", + display_name: "GPT 4.1 Legacy", + enabled: false, +}); + +const inaccessibleModelConfig = buildModelConfig({ + id: "model-inaccessible", + provider: "bedrock", + model: "claude-3-5-sonnet", + display_name: "Bedrock Claude", +}); + +const modelConfigs = [ + defaultModelConfig, + claudeModelConfig, + disabledModelConfig, + inaccessibleModelConfig, +]; + +const modelOptions: ModelSelectorOption[] = [ + { + id: defaultModelConfig.id, + provider: defaultModelConfig.provider, + model: defaultModelConfig.model, + displayName: defaultModelConfig.display_name, + contextLimit: defaultModelConfig.context_limit, + }, + { + id: claudeModelConfig.id, + provider: claudeModelConfig.provider, + model: claudeModelConfig.model, + displayName: claudeModelConfig.display_name, + contextLimit: claudeModelConfig.context_limit, + }, +]; + +const buildOverridesResponse = ( + overrides: Partial = {}, +): TypesGen.UserChatPersonalModelOverridesResponse => ({ + enabled: true, + root: buildOverride("root"), + general: buildOverride("general"), + explore: buildOverride("explore"), + deployment_defaults: buildDeploymentDefaults({ + general: buildDeploymentDefault("general", { + model_config_id: claudeModelConfig.id, + }), + explore: buildDeploymentDefault("explore", { + model_config_id: claudeModelConfig.id, + }), + }), + ...overrides, +}); + +const makeArgs = ( + overrides: Partial = {}, +): AgentSettingsUserAgentsPageViewProps => ({ + overridesData: buildOverridesResponse(), + overridesError: undefined, + onRetryOverrides: fn(), + isRetryingOverrides: false, + isLoadingOverrides: false, + modelOptions, + modelConfigs, + modelConfigsError: undefined, + isLoadingModels: false, + onSaveRootModelOverride: fn(), + isSavingRootModelOverride: false, + isSaveRootModelOverrideError: false, + onSaveGeneralModelOverride: fn(), + isSavingGeneralModelOverride: false, + isSaveGeneralModelOverrideError: false, + onSaveExploreModelOverride: fn(), + isSavingExploreModelOverride: false, + isSaveExploreModelOverrideError: false, + ...overrides, +}); + +const getSection = async ( + canvasElement: HTMLElement, + headingName: string, +): Promise => { + const canvas = within(canvasElement); + const heading = await canvas.findByRole("heading", { name: headingName }); + const section = heading.closest("section"); + if (!(section instanceof HTMLElement)) { + throw new Error( + `Expected ${headingName} heading to live inside a section.`, + ); + } + return section; +}; + +const selectOption = async ( + section: HTMLElement, + canvasElement: HTMLElement, + comboboxName: string | RegExp, + optionName: string | RegExp, +) => { + await userEvent.click( + within(section).getByRole("combobox", { name: comboboxName }), + ); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click(await body.findByRole("option", { name: optionName })); +}; + +const meta = { + title: "pages/AgentsPage/AgentSettingsUserAgentsPageView", + component: AgentSettingsUserAgentsPageView, + args: makeArgs(), +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const EnabledWithNoSavedValues: Story = { + args: makeArgs(), + play: async ({ canvasElement }) => { + const rootSection = await getSection(canvasElement, "Root agent model"); + const generalSection = await getSection( + canvasElement, + "General subagent model", + ); + const exploreSection = await getSection( + canvasElement, + "Explore subagent model", + ); + + expect(rootSection).toHaveTextContent("Chat default: GPT 4.1 Mini"); + expect(generalSection).toHaveTextContent( + "Deployment default: Claude Sonnet 4", + ); + expect(exploreSection).toHaveTextContent( + "Deployment default: Claude Sonnet 4", + ); + + for (const section of [rootSection, generalSection, exploreSection]) { + expect( + within(section).getByRole("button", { name: "Save" }), + ).toBeDisabled(); + } + }, +}; + +export const EnabledWithSavedValues: Story = { + args: makeArgs({ + overridesData: buildOverridesResponse({ + root: buildOverride("root", { + mode: "chat_default", + is_set: true, + }), + general: buildOverride("general", { + mode: "deployment_default", + is_set: true, + }), + explore: buildOverride("explore", { + mode: "model", + model_config_id: claudeModelConfig.id, + is_set: true, + }), + }), + }), + play: async ({ canvasElement, args }) => { + const rootSection = await getSection(canvasElement, "Root agent model"); + await selectOption( + rootSection, + canvasElement, + "Root agent model behavior", + /Claude Sonnet 4/i, + ); + const rootSaveButton = within(rootSection).getByRole("button", { + name: "Save", + }); + await waitFor(() => { + expect(rootSaveButton).toBeEnabled(); + }); + await userEvent.click(rootSaveButton); + await waitFor(() => { + expect(args.onSaveRootModelOverride).toHaveBeenCalledWith( + { mode: "model", model_config_id: claudeModelConfig.id }, + expect.anything(), + ); + }); + + const generalSection = await getSection( + canvasElement, + "General subagent model", + ); + await selectOption( + generalSection, + canvasElement, + "General subagent model behavior", + /Chat default/i, + ); + await userEvent.click( + within(generalSection).getByRole("button", { name: "Save" }), + ); + await waitFor(() => { + expect(args.onSaveGeneralModelOverride).toHaveBeenCalledWith( + { mode: "chat_default", model_config_id: "" }, + expect.anything(), + ); + }); + }, +}; + +export const MalformedSavedValues: Story = { + args: makeArgs({ + overridesData: buildOverridesResponse({ + root: buildOverride("root", { is_malformed: true }), + general: buildOverride("general", { is_malformed: true }), + explore: buildOverride("explore", { is_malformed: true }), + }), + }), + play: async ({ canvasElement, args }) => { + const rootSection = await getSection(canvasElement, "Root agent model"); + const generalSection = await getSection( + canvasElement, + "General subagent model", + ); + const exploreSection = await getSection( + canvasElement, + "Explore subagent model", + ); + + for (const section of [rootSection, generalSection, exploreSection]) { + expect(within(section).getByText(MALFORMED_WARNING)).toBeInTheDocument(); + expect( + within(section).getByRole("button", { name: "Save" }), + ).toBeEnabled(); + } + + await userEvent.click( + within(rootSection).getByRole("button", { name: "Save" }), + ); + await waitFor(() => { + expect(args.onSaveRootModelOverride).toHaveBeenCalledWith( + { mode: "chat_default", model_config_id: "" }, + expect.anything(), + ); + }); + }, +}; + +export const MalformedEmptyModelSavedValues: Story = { + args: makeArgs({ + overridesData: buildOverridesResponse({ + root: buildOverride("root", { + mode: "model", + model_config_id: "", + is_set: true, + is_malformed: true, + }), + general: buildOverride("general", { + mode: "model", + model_config_id: "", + is_set: true, + is_malformed: true, + }), + explore: buildOverride("explore", { + mode: "model", + model_config_id: "", + is_set: true, + is_malformed: true, + }), + }), + }), + play: async ({ canvasElement, args }) => { + const rootSection = await getSection(canvasElement, "Root agent model"); + const generalSection = await getSection( + canvasElement, + "General subagent model", + ); + const exploreSection = await getSection( + canvasElement, + "Explore subagent model", + ); + + expect(rootSection).toHaveTextContent("Chat default"); + expect(generalSection).toHaveTextContent("Deployment default"); + expect(exploreSection).toHaveTextContent("Deployment default"); + + for (const section of [rootSection, generalSection, exploreSection]) { + expect(within(section).getByText(MALFORMED_WARNING)).toBeInTheDocument(); + expect( + within(section).getByRole("button", { name: "Save" }), + ).toBeEnabled(); + } + + await userEvent.click( + within(rootSection).getByRole("button", { name: "Save" }), + ); + await waitFor(() => { + expect(args.onSaveRootModelOverride).toHaveBeenCalledWith( + { mode: "chat_default", model_config_id: "" }, + expect.anything(), + ); + }); + + await userEvent.click( + within(generalSection).getByRole("button", { name: "Save" }), + ); + await waitFor(() => { + expect(args.onSaveGeneralModelOverride).toHaveBeenCalledWith( + { mode: "deployment_default", model_config_id: "" }, + expect.anything(), + ); + }); + }, +}; + +export const UnavailableSavedModels: Story = { + args: makeArgs({ + overridesData: buildOverridesResponse({ + root: buildOverride("root", { + mode: "model", + model_config_id: disabledModelConfig.id, + is_set: true, + }), + general: buildOverride("general", { + mode: "model", + model_config_id: inaccessibleModelConfig.id, + is_set: true, + }), + }), + }), + play: async ({ canvasElement }) => { + const rootSection = await getSection(canvasElement, "Root agent model"); + const generalSection = await getSection( + canvasElement, + "General subagent model", + ); + + expect(rootSection).toHaveTextContent("Unavailable: GPT 4.1 Legacy"); + expect(generalSection).toHaveTextContent("Unavailable: Bedrock Claude"); + expect( + within(rootSection).getByText(UNAVAILABLE_WARNING), + ).toBeInTheDocument(); + expect( + within(generalSection).getByText(UNAVAILABLE_WARNING), + ).toBeInTheDocument(); + }, +}; + +export const ModelConfigsError: Story = { + args: makeArgs({ + modelConfigsError: new Error("Failed to load model configs."), + overridesData: buildOverridesResponse({ + root: buildOverride("root", { + mode: "model", + model_config_id: claudeModelConfig.id, + is_set: true, + }), + general: buildOverride("general", { + mode: "model", + model_config_id: claudeModelConfig.id, + is_set: true, + }), + explore: buildOverride("explore", { + mode: "model", + model_config_id: claudeModelConfig.id, + is_set: true, + }), + }), + }), + play: async ({ canvasElement }) => { + const rootSection = await getSection(canvasElement, "Root agent model"); + const generalSection = await getSection( + canvasElement, + "General subagent model", + ); + const exploreSection = await getSection( + canvasElement, + "Explore subagent model", + ); + + for (const section of [rootSection, generalSection, exploreSection]) { + expect( + within(section).getByText("Failed to load model configs."), + ).toBeInTheDocument(); + expect( + within(section).getByRole("combobox", { name: /behavior/i }), + ).toBeEnabled(); + } + + await selectOption( + rootSection, + canvasElement, + "Root agent model behavior", + /Chat default/i, + ); + await selectOption( + generalSection, + canvasElement, + "General subagent model behavior", + /Deployment default/i, + ); + await selectOption( + exploreSection, + canvasElement, + "Explore subagent model behavior", + /Chat default/i, + ); + + expect(rootSection).toHaveTextContent("Chat default"); + expect(generalSection).toHaveTextContent("Deployment default"); + expect(exploreSection).toHaveTextContent("Chat default"); + }, +}; + +export const LoadingState: Story = { + args: makeArgs({ + overridesData: undefined, + isLoadingOverrides: true, + modelOptions: [], + isLoadingModels: true, + }), + play: async ({ canvasElement }) => { + const rootSection = await getSection(canvasElement, "Root agent model"); + expect( + within(rootSection).getByRole("combobox", { + name: "Root agent model behavior", + }), + ).toBeDisabled(); + expect( + within(rootSection).getByRole("button", { name: "Save" }), + ).toBeDisabled(); + }, +}; + +export const OverridesError: Story = { + args: makeArgs({ + overridesData: undefined, + overridesError: new Error("Failed to load overrides"), + }), + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + expect( + await canvas.findByText("Failed to load overrides"), + ).toBeInTheDocument(); + + const retryButton = canvas.getByRole("button", { name: "Retry" }); + expect(retryButton).toBeEnabled(); + await userEvent.click(retryButton); + expect(args.onRetryOverrides).toHaveBeenCalled(); + + const rootSection = await getSection(canvasElement, "Root agent model"); + const generalSection = await getSection( + canvasElement, + "General subagent model", + ); + const exploreSection = await getSection( + canvasElement, + "Explore subagent model", + ); + for (const section of [rootSection, generalSection, exploreSection]) { + expect( + within(section).getByRole("combobox", { name: /behavior/i }), + ).toBeDisabled(); + expect( + within(section).getByRole("button", { name: "Save" }), + ).toBeDisabled(); + } + }, +}; + +export const SaveErrorState: Story = { + args: makeArgs({ + isSaveGeneralModelOverrideError: true, + }), + play: async ({ canvasElement }) => { + const generalSection = await getSection( + canvasElement, + "General subagent model", + ); + expect( + within(generalSection).getByText( + "Failed to save general subagent model override.", + ), + ).toBeInTheDocument(); + }, +}; + +export const AdminDisabledReadOnly: Story = { + args: makeArgs({ + overridesData: buildOverridesResponse({ + enabled: false, + root: buildOverride("root", { + mode: "model", + model_config_id: defaultModelConfig.id, + is_set: true, + }), + }), + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByText( + /Personal model overrides are disabled by an administrator/i, + ), + ).toBeInTheDocument(); + const rootSection = await getSection(canvasElement, "Root agent model"); + expect( + within(rootSection).getByRole("combobox", { + name: "Root agent model behavior", + }), + ).toBeDisabled(); + expect( + within(rootSection).getByRole("button", { name: "Save" }), + ).toBeDisabled(); + }, +}; + +export const InvalidRootDeploymentDefault: Story = { + args: makeArgs({ + overridesData: buildOverridesResponse({ + root: buildOverride("root", { + mode: "deployment_default", + is_set: true, + }), + }), + }), + play: async ({ canvasElement, args }) => { + const rootSection = await getSection(canvasElement, "Root agent model"); + expect(rootSection).toHaveTextContent("Invalid deployment default"); + expect( + within(rootSection).getByText( + /The saved root override uses the deployment default/i, + ), + ).toBeInTheDocument(); + expect( + within(rootSection).getByRole("button", { name: "Save" }), + ).toBeDisabled(); + + await selectOption( + rootSection, + canvasElement, + "Root agent model behavior", + /Chat default/i, + ); + await userEvent.click( + within(rootSection).getByRole("button", { name: "Save" }), + ); + await waitFor(() => { + expect(args.onSaveRootModelOverride).toHaveBeenCalledWith( + { mode: "chat_default", model_config_id: "" }, + expect.anything(), + ); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.tsx new file mode 100644 index 0000000000..cbc7385fea --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.tsx @@ -0,0 +1,139 @@ +import type { FC } from "react"; +import type * as TypesGen from "#/api/typesGenerated"; +import { Alert, AlertDescription } from "#/components/Alert/Alert"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { Button } from "#/components/Button/Button"; +import type { ModelSelectorOption } from "./components/ChatElements"; +import { + PersonalModelOverrideRow, + type SavePersonalOverride, +} from "./components/PersonalModelOverrideRow"; +import { SectionHeader } from "./components/SectionHeader"; + +export interface AgentSettingsUserAgentsPageViewProps { + overridesData?: TypesGen.UserChatPersonalModelOverridesResponse; + overridesError: unknown; + onRetryOverrides?: () => void; + isRetryingOverrides?: boolean; + isLoadingOverrides: boolean; + modelOptions: readonly ModelSelectorOption[]; + modelConfigs: readonly TypesGen.ChatModelConfig[]; + modelConfigsError: unknown; + isLoadingModels: boolean; + onSaveRootModelOverride: SavePersonalOverride; + isSavingRootModelOverride: boolean; + isSaveRootModelOverrideError: boolean; + onSaveGeneralModelOverride: SavePersonalOverride; + isSavingGeneralModelOverride: boolean; + isSaveGeneralModelOverrideError: boolean; + onSaveExploreModelOverride: SavePersonalOverride; + isSavingExploreModelOverride: boolean; + isSaveExploreModelOverrideError: boolean; +} + +export const AgentSettingsUserAgentsPageView: FC< + AgentSettingsUserAgentsPageViewProps +> = ({ + overridesData, + overridesError, + onRetryOverrides, + isRetryingOverrides = false, + isLoadingOverrides, + modelOptions, + modelConfigs, + modelConfigsError, + isLoadingModels, + onSaveRootModelOverride, + isSavingRootModelOverride, + isSaveRootModelOverrideError, + onSaveGeneralModelOverride, + isSavingGeneralModelOverride, + isSaveGeneralModelOverrideError, + onSaveExploreModelOverride, + isSavingExploreModelOverride, + isSaveExploreModelOverrideError, +}) => { + const personalOverridesEnabled = overridesData?.enabled ?? true; + const isLoading = isLoadingOverrides || isLoadingModels; + const isDisabled = isLoading || !personalOverridesEnabled; + + return ( +
+ + {overridesError ? ( +
+ + {onRetryOverrides && ( + + )} +
+ ) : null} + {!personalOverridesEnabled && ( + + + Personal model overrides are disabled by an administrator. Saved + values are shown for reference, but changes cannot be saved. + + + )} + + + +
+ ); +}; diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index 242d5f57c7..0799a089c1 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -32,6 +32,7 @@ import { unpinChat, updateChatTitle, updateInfiniteChatsCache, + userChatPersonalModelOverrides, } from "#/api/queries/chats"; import { workspaceById } from "#/api/queries/workspaces"; import type * as TypesGen from "#/api/typesGenerated"; @@ -118,10 +119,13 @@ const AgentsPage: FC = () => { ); // Model queries are kept here for the sidebar, which displays // model info alongside each chat. Child routes that need models - // subscribe to the same queries independently — react-query + // subscribe to the same queries independently, and react-query // deduplicates the requests. const chatModelsQuery = useQuery(chatModels()); const chatModelConfigsQuery = useQuery(chatModelConfigs()); + const personalModelOverridesQuery = useQuery( + userChatPersonalModelOverrides(), + ); const [chatErrorReasons, setChatErrorReasons] = useState< Record >({}); @@ -529,7 +533,7 @@ const AgentsPage: FC = () => { return; } if (chatEvent.kind === "diff_status_change") { - // Only refetch the diff file contents — the chat's + // Only refetch the diff file contents. The chat's // diff_status field is already written into the // chatKey and infinite-list caches below. void queryClient.invalidateQueries({ @@ -643,6 +647,9 @@ const AgentsPage: FC = () => { onRenameTitle={requestRenameTitle} regeneratingTitleChatIds={regeneratingTitleChatIds} onToggleSidebarCollapsed={handleToggleSidebarCollapsed} + isPersonalModelOverridesEnabled={ + personalModelOverridesQuery.data?.enabled + } isAgentsAdmin={isAgentsAdmin} hasNextPage={chatsQuery.hasNextPage} onLoadMore={() => void chatsQuery.fetchNextPage()} diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index c7aca855f1..930e415a3d 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -155,6 +155,10 @@ const fixedNow = dayjs("2026-03-12T12:00:00"); const AgentsRouteElement = () => ( Promise; regeneratingTitleChatIds: readonly string[]; onToggleSidebarCollapsed: () => void; + isPersonalModelOverridesEnabled?: boolean; isAgentsAdmin: boolean; hasNextPage: boolean | undefined; onLoadMore: () => void; @@ -104,6 +105,7 @@ export const AgentsPageView: FC = ({ onRenameTitle, regeneratingTitleChatIds, onToggleSidebarCollapsed, + isPersonalModelOverridesEnabled, isAgentsAdmin, hasNextPage, onLoadMore, @@ -194,6 +196,7 @@ export const AgentsPageView: FC = ({ archivedFilter={archivedFilter} onArchivedFilterChange={onArchivedFilterChange} onCollapse={onCollapseSidebar} + isPersonalModelOverridesEnabled={isPersonalModelOverridesEnabled} isAdmin={isAgentsAdmin} /> diff --git a/site/src/pages/AgentsPage/components/AdminPersonalModelOverridesSettings.stories.tsx b/site/src/pages/AgentsPage/components/AdminPersonalModelOverridesSettings.stories.tsx new file mode 100644 index 0000000000..1ab37187ad --- /dev/null +++ b/site/src/pages/AgentsPage/components/AdminPersonalModelOverridesSettings.stories.tsx @@ -0,0 +1,145 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +import { AdminPersonalModelOverridesSettings } from "./AdminPersonalModelOverridesSettings"; + +const baseArgs = { + adminSettings: { allow_users: false }, + adminSettingsError: undefined, + onRetryAdminSettings: fn(), + isRetryingAdminSettings: false, + onSaveAdminSetting: fn(), + isSavingAdminSetting: false, + isSaveAdminSettingError: false, +}; + +const meta = { + title: "pages/AgentsPage/components/AdminPersonalModelOverridesSettings", + component: AdminPersonalModelOverridesSettings, + args: baseArgs, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const FeatureDisabled: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable users to define their personal overrides", + }); + + expect( + await canvas.findByText( + "Enable users to define their personal overrides", + ), + ).toBeInTheDocument(); + expect(toggle).not.toBeChecked(); + expect(canvas.getByRole("button", { name: "Save" })).toBeDisabled(); + }, +}; + +export const LoadingState: Story = { + args: { + adminSettings: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect( + await canvas.findByText("Loading personal model override settings..."), + ).toBeInTheDocument(); + expect( + canvas.getByRole("switch", { + name: "Enable users to define their personal overrides", + }), + ).toBeDisabled(); + expect(canvas.getByRole("button", { name: "Save" })).toBeDisabled(); + }, +}; + +export const LoadError: Story = { + args: { + adminSettings: undefined, + adminSettingsError: new Error("Failed to load personal model overrides."), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + expect( + await canvas.findByText("Failed to load personal model overrides."), + ).toBeInTheDocument(); + expect( + canvas.queryByText("Loading personal model override settings..."), + ).not.toBeInTheDocument(); + expect(canvas.getByRole("button", { name: "Save" })).toBeDisabled(); + await userEvent.click(canvas.getByRole("button", { name: "Retry" })); + expect(args.onRetryAdminSettings).toHaveBeenCalled(); + }, +}; + +export const FeatureEnabled: Story = { + args: { + adminSettings: { allow_users: true }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable users to define their personal overrides", + }); + + expect(toggle).toBeChecked(); + expect(canvas.getByRole("button", { name: "Save" })).toBeDisabled(); + }, +}; + +export const Saving: Story = { + args: { + isSavingAdminSetting: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable users to define their personal overrides", + }); + + expect(toggle).toBeDisabled(); + expect(canvas.getByRole("button", { name: "Save" })).toBeDisabled(); + }, +}; + +export const SaveError: Story = { + args: { + isSaveAdminSettingError: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect( + await canvas.findByText( + "Failed to save personal model override settings.", + ), + ).toBeInTheDocument(); + }, +}; + +export const SavesChangedSetting: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable users to define their personal overrides", + }); + const saveButton = canvas.getByRole("button", { name: "Save" }); + + await userEvent.click(toggle); + await waitFor(() => { + expect(saveButton).toBeEnabled(); + }); + await userEvent.click(saveButton); + await waitFor(() => { + expect(args.onSaveAdminSetting).toHaveBeenCalledWith( + { allow_users: true }, + expect.anything(), + ); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/components/AdminPersonalModelOverridesSettings.tsx b/site/src/pages/AgentsPage/components/AdminPersonalModelOverridesSettings.tsx new file mode 100644 index 0000000000..727d0f0289 --- /dev/null +++ b/site/src/pages/AgentsPage/components/AdminPersonalModelOverridesSettings.tsx @@ -0,0 +1,122 @@ +import { useFormik } from "formik"; +import type { FC } from "react"; +import type * as TypesGen from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { Button } from "#/components/Button/Button"; +import { Switch } from "#/components/Switch/Switch"; + +interface MutationCallbacks { + onSuccess?: () => void; + onError?: () => void; +} + +export type SavePersonalModelOverridesAdminSetting = ( + req: TypesGen.UpdateChatPersonalModelOverridesAdminSettingsRequest, + options?: MutationCallbacks, +) => void; + +interface AdminPersonalModelOverridesSettingsProps { + adminSettings: TypesGen.ChatPersonalModelOverridesAdminSettings | undefined; + adminSettingsError?: unknown; + onRetryAdminSettings?: () => void; + isRetryingAdminSettings?: boolean; + onSaveAdminSetting: SavePersonalModelOverridesAdminSetting; + isSavingAdminSetting: boolean; + isSaveAdminSettingError: boolean; +} + +export const AdminPersonalModelOverridesSettings: FC< + AdminPersonalModelOverridesSettingsProps +> = ({ + adminSettings, + adminSettingsError, + onRetryAdminSettings, + isRetryingAdminSettings = false, + onSaveAdminSetting, + isSavingAdminSetting, + isSaveAdminSettingError, +}) => { + const hasLoadedAdminSettings = adminSettings !== undefined; + const hasAdminSettingsError = adminSettingsError != null; + const form = useFormik({ + enableReinitialize: true, + initialValues: { + allow_users: adminSettings?.allow_users ?? false, + }, + onSubmit: (values, { resetForm }) => { + onSaveAdminSetting( + { + allow_users: values.allow_users, + }, + { + onSuccess: () => { + resetForm({ values }); + }, + }, + ); + }, + }); + const isDisabled = isSavingAdminSetting || !hasLoadedAdminSettings; + + return ( +
+
+
+

+ Enable users to define their personal overrides +

+

+ Lets users choose personal models for root chats, General subagents, + and Explore subagents. When disabled, saved user settings remain + stored but are ignored at runtime. +

+
+ { + void form.setFieldValue("allow_users", checked); + }} + aria-label="Enable users to define their personal overrides" + type="button" + disabled={isDisabled} + /> +
+ {hasAdminSettingsError ? ( +
+ + {onRetryAdminSettings && ( + + )} +
+ ) : ( + !hasLoadedAdminSettings && ( +

+ Loading personal model override settings... +

+ ) + )} +
+ +
+ {isSaveAdminSettingError && ( +

+ Failed to save personal model override settings. +

+ )} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index a2087a5d67..88e76edddb 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -9,6 +9,7 @@ import { within, } from "storybook/test"; import { API } from "#/api/api"; +import type * as TypesGen from "#/api/typesGenerated"; import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; import { MockDefaultOrganization, @@ -26,6 +27,7 @@ const permittedOrgsKey = [ ]; const modelConfigID = "model-config-1"; +const claudeModelConfigID = "model-config-claude"; const modelOptions = [ { @@ -34,8 +36,52 @@ const modelOptions = [ model: "gpt-4o", displayName: "GPT-4o", }, + { + id: claudeModelConfigID, + provider: "anthropic", + model: "claude-sonnet-4", + displayName: "Claude Sonnet 4", + }, ] as const; +const buildModelConfig = ( + overrides: Partial = {}, +): TypesGen.ChatModelConfig => ({ + id: modelConfigID, + provider: "openai", + model: "gpt-4o", + display_name: "GPT-4o", + enabled: true, + is_default: false, + context_limit: 200_000, + compression_threshold: 70, + created_at: "2026-02-18T00:00:00.000Z", + updated_at: "2026-02-18T00:00:00.000Z", + ...overrides, +}); + +const defaultModelConfigs: TypesGen.ChatModelConfig[] = [ + buildModelConfig({ is_default: true }), + buildModelConfig({ + id: claudeModelConfigID, + provider: "anthropic", + model: "claude-sonnet-4", + display_name: "Claude Sonnet 4", + context_limit: 200_000, + }), +]; + +const buildRootPersonalModelOverride = ( + overrides: Partial = {}, +): TypesGen.ChatPersonalModelOverride => ({ + context: "root", + mode: "chat_default", + model_config_id: "", + is_set: true, + is_malformed: false, + ...overrides, +}); + const mock403Error = Object.assign( new Error("Request failed with status code 403"), { @@ -94,6 +140,173 @@ const mockPermittedOrganizations = (permissions: Record) => { export const Default: Story = {}; +const submitMessage = async (canvasElement: HTMLElement, message: string) => { + const canvas = within(canvasElement); + const input = canvas.getByTestId("chat-message-input"); + await userEvent.click(input); + await userEvent.keyboard(message); + await userEvent.click(canvas.getByRole("button", { name: "Send" })); +}; + +const getCreateOptions = (onCreateChat: unknown): CreateChatSubmission => { + const mock = onCreateChat as ReturnType; + const options = mock.mock.calls[0]?.[0] as CreateChatSubmission | undefined; + if (!options) { + throw new Error("Expected onCreateChat to receive options."); + } + return options; +}; + +type CreateChatSubmission = { + model?: string; +}; + +export const RootPersonalModelOverrideModelSelected: Story = { + args: { + ...defaultArgs, + onCreateChat: fn().mockResolvedValue(undefined), + modelConfigs: defaultModelConfigs, + rootPersonalModelOverride: buildRootPersonalModelOverride({ + mode: "model", + model_config_id: claudeModelConfigID, + }), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("combobox", { name: "Claude Sonnet 4" }), + ).toBeInTheDocument(); + await submitMessage(canvasElement, "create with saved root model"); + await waitFor(() => { + expect(args.onCreateChat).toHaveBeenCalled(); + }); + expect(getCreateOptions(args.onCreateChat).model).toBe(claudeModelConfigID); + }, +}; + +export const RootChatDefaultSubmitsDisplayedModel: Story = { + args: { + ...defaultArgs, + onCreateChat: fn().mockResolvedValue(undefined), + modelConfigs: defaultModelConfigs, + rootPersonalModelOverride: buildRootPersonalModelOverride({ + mode: "chat_default", + model_config_id: "", + }), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("combobox", { name: "GPT-4o" }), + ).toBeInTheDocument(); + await submitMessage(canvasElement, "create with chat default"); + await waitFor(() => { + expect(args.onCreateChat).toHaveBeenCalled(); + }); + expect(getCreateOptions(args.onCreateChat).model).toBe(modelConfigID); + }, +}; + +export const RootOverrideMissingFromCatalog: Story = { + args: { + ...defaultArgs, + onCreateChat: fn().mockResolvedValue(undefined), + modelConfigs: defaultModelConfigs, + rootPersonalModelOverride: buildRootPersonalModelOverride({ + mode: "model", + model_config_id: "model-does-not-exist", + is_set: true, + is_malformed: false, + }), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("combobox", { name: "GPT-4o" }), + ).toBeInTheDocument(); + await submitMessage(canvasElement, "create with missing root model"); + await waitFor(() => { + expect(args.onCreateChat).toHaveBeenCalled(); + }); + expect(getCreateOptions(args.onCreateChat).model).toBe(modelConfigID); + }, +}; + +export const MalformedRootOverrideUsesDefaultModel: Story = { + args: { + ...defaultArgs, + onCreateChat: fn().mockResolvedValue(undefined), + modelConfigs: defaultModelConfigs, + rootPersonalModelOverride: buildRootPersonalModelOverride({ + mode: "model", + model_config_id: claudeModelConfigID, + is_malformed: true, + }), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("combobox", { name: "GPT-4o" }), + ).toBeInTheDocument(); + expect( + canvas.queryByRole("combobox", { name: "Claude Sonnet 4" }), + ).not.toBeInTheDocument(); + await submitMessage(canvasElement, "create with malformed root model"); + await waitFor(() => { + expect(args.onCreateChat).toHaveBeenCalled(); + }); + expect(getCreateOptions(args.onCreateChat).model).toBe(modelConfigID); + }, +}; + +export const LastUsedModelFallbackWithoutRootOverride: Story = { + args: { + ...defaultArgs, + onCreateChat: fn().mockResolvedValue(undefined), + modelConfigs: defaultModelConfigs, + }, + beforeEach: () => { + localStorage.clear(); + localStorage.setItem("agents.last-model-config-id", claudeModelConfigID); + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("combobox", { name: "Claude Sonnet 4" }), + ).toBeInTheDocument(); + await submitMessage(canvasElement, "create with last used model"); + await waitFor(() => { + expect(args.onCreateChat).toHaveBeenCalled(); + }); + expect(getCreateOptions(args.onCreateChat).model).toBe(claudeModelConfigID); + }, +}; + +export const ManualSelectionOverridesRootChatDefault: Story = { + args: { + ...defaultArgs, + onCreateChat: fn().mockResolvedValue(undefined), + modelConfigs: defaultModelConfigs, + rootPersonalModelOverride: buildRootPersonalModelOverride({ + mode: "chat_default", + model_config_id: "", + }), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("combobox", { name: "GPT-4o" })); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + await body.findByRole("option", { name: /Claude Sonnet 4/i }), + ); + await submitMessage(canvasElement, "create with manual model"); + await waitFor(() => { + expect(args.onCreateChat).toHaveBeenCalled(); + }); + expect(getCreateOptions(args.onCreateChat).model).toBe(claudeModelConfigID); + }, +}; + const mockWorkspaces = [ { ...MockWorkspace, @@ -229,6 +442,20 @@ export const LoadingModelCatalog: Story = { }, }; +export const LoadingPersonalModelOverrides: Story = { + args: { + ...defaultArgs, + isPersonalModelOverridesLoading: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole("textbox")).toHaveAttribute( + "aria-disabled", + "true", + ); + }, +}; + export const NoModelsConfigured: Story = { args: { ...defaultArgs, diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index bfc9ed8edd..3bfabaec5b 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -84,7 +84,7 @@ export function useEmptyStateDraft() { try { localStorage.setItem(emptyInputStorageKey, serializedEditorState); } catch { - // QuotaExceededError — silently discard the draft. + // QuotaExceededError, silently discard the draft. } } else { localStorage.removeItem(emptyInputStorageKey); @@ -125,6 +125,8 @@ interface AgentCreateFormProps { isModelCatalogLoading: boolean; modelConfigs: readonly TypesGen.ChatModelConfig[]; isModelConfigsLoading: boolean; + rootPersonalModelOverride?: TypesGen.ChatPersonalModelOverride; + isPersonalModelOverridesLoading?: boolean; mcpServers?: readonly TypesGen.MCPServerConfig[]; onMCPAuthComplete?: (serverId: string) => void; workspaceCount: number | undefined; @@ -143,6 +145,8 @@ export const AgentCreateForm: FC = ({ modelConfigs, isModelCatalogLoading, isModelConfigsLoading, + rootPersonalModelOverride, + isPersonalModelOverridesLoading = false, mcpServers, onMCPAuthComplete, workspaceCount: _workspaceCount, @@ -161,6 +165,10 @@ export const AgentCreateForm: FC = ({ const [initialLastModelConfigID] = useState(() => { return localStorage.getItem(lastModelConfigIDStorageKey) ?? ""; }); + /* + * Model precedence: user click > root override (specific model) > root + * override (chat_default, resolved) > last-used > default > first available. + */ const lastUsedModelID = initialLastModelConfigID && modelOptions.some((option) => option.id === initialLastModelConfigID) @@ -177,17 +185,45 @@ export const AgentCreateForm: FC = ({ ? defaultModelConfig.id : ""; })(); - const preferredModelID = + const isUsableRootPersonalOverride = + rootPersonalModelOverride?.is_set === true && + !rootPersonalModelOverride.is_malformed; + const rootOverrideModelID = + isUsableRootPersonalOverride && + rootPersonalModelOverride.mode === "model" && + modelOptions.some( + (option) => option.id === rootPersonalModelOverride.model_config_id, + ) + ? rootPersonalModelOverride.model_config_id + : ""; + const isRootOverrideChatDefault = + isUsableRootPersonalOverride && + rootPersonalModelOverride.mode === "chat_default"; + const rootOverrideDisplayModelID = isRootOverrideChatDefault + ? defaultModelID || (modelOptions[0]?.id ?? "") + : rootOverrideModelID; + const fallbackModelID = lastUsedModelID || defaultModelID || (modelOptions[0]?.id ?? ""); + const preferredModelID = rootOverrideDisplayModelID || fallbackModelID; const [userSelectedModel, setUserSelectedModel] = useState(""); const [hasUserSelectedModel, setHasUserSelectedModel] = useState(false); + const hasValidUserSelectedModel = + hasUserSelectedModel && + modelOptions.some((modelOption) => modelOption.id === userSelectedModel); // Derive the effective model every render so we never reference // a stale model id and can honor fallback precedence. - const selectedModel = - hasUserSelectedModel && - modelOptions.some((modelOption) => modelOption.id === userSelectedModel) - ? userSelectedModel - : preferredModelID; + const selectedModel = hasValidUserSelectedModel + ? userSelectedModel + : preferredModelID; + const submittedModel = (() => { + if (hasValidUserSelectedModel) { + return userSelectedModel; + } + if (rootOverrideModelID) { + return rootOverrideModelID; + } + return selectedModel || undefined; + })(); const initialOrg = organizations.find((o) => o.is_default) ?? organizations[0]; const [selectedWorkspaceId, setSelectedWorkspaceId] = useState( @@ -316,7 +352,7 @@ export const AgentCreateForm: FC = ({ message, fileIDs, workspaceId: effectiveWorkspaceId ?? undefined, - model: selectedModel || undefined, + model: submittedModel, organizationId, mcpServerIds: effectiveMCPServerIds.length > 0 @@ -384,7 +420,7 @@ export const AgentCreateForm: FC = ({ setPrevPermittedOrgs(permittedOrgs); if (selectedOrg && !permittedOrgs.some((o) => o.id === selectedOrg.id)) { // Fall back through: first permitted org, then the - // dashboard default. Never null out selectedOrg — + // dashboard default. Never null out selectedOrg. // organizationId must always be a valid UUID for the // create-chat request. const nextOrg = permittedOrgs[0] ?? initialOrg ?? null; @@ -460,7 +496,9 @@ export const AgentCreateForm: FC = ({ { return `${k}K context window`; }; -const getOptionLabel = (option: ModelSelectorOption): string => { - const displayName = option.displayName.trim(); - if (displayName) { - return displayName; - } - const model = option.model.trim(); - if (model) { - return model; - } - return option.id; -}; - export const ModelSelector: FC = ({ options, value, @@ -113,7 +101,7 @@ export const ModelSelector: FC = ({ onOpenChange={onOpenChange} > span]:truncate [&>svg]:shrink-0 [&>svg]:transition-colors [&>svg]:hover:text-content-primary", className, @@ -121,7 +109,7 @@ export const ModelSelector: FC = ({ onTouchStart={onTriggerTouchStart} > - {selectedModel ? getOptionLabel(selectedModel) : placeholder} + {selectedModel ? selectedModel.displayName : placeholder} = ({ providerLabel, isSelected, }) => { - const label = getOptionLabel(option); + const label = option.displayName; const contextInfo = option.contextLimit != null && option.contextLimit > 0 ? formatContextLimit(option.contextLimit) diff --git a/site/src/pages/AgentsPage/components/ModelOverrideAlerts.tsx b/site/src/pages/AgentsPage/components/ModelOverrideAlerts.tsx new file mode 100644 index 0000000000..e12af6c764 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ModelOverrideAlerts.tsx @@ -0,0 +1,41 @@ +import type { FC, ReactNode } from "react"; +import { Alert, AlertDescription } from "#/components/Alert/Alert"; + +interface ModelOverrideAlertsProps { + isUnavailableSavedModel: boolean; + unavailableMessage: ReactNode; + isMalformedOverride: boolean; + malformedMessage: ReactNode; + modelConfigsError: unknown; + children?: ReactNode; +} + +export const ModelOverrideAlerts: FC = ({ + isUnavailableSavedModel, + unavailableMessage, + isMalformedOverride, + malformedMessage, + modelConfigsError, + children, +}) => { + return ( + <> + {isUnavailableSavedModel && ( + + {unavailableMessage} + + )} + {isMalformedOverride && ( + + {malformedMessage} + + )} + {children} + {Boolean(modelConfigsError) && ( +

+ Failed to load model configs. +

+ )} + + ); +}; diff --git a/site/src/pages/AgentsPage/components/PersonalModelOverrideRow.tsx b/site/src/pages/AgentsPage/components/PersonalModelOverrideRow.tsx new file mode 100644 index 0000000000..e07e8a2b23 --- /dev/null +++ b/site/src/pages/AgentsPage/components/PersonalModelOverrideRow.tsx @@ -0,0 +1,408 @@ +import { useFormik } from "formik"; +import { Select as SelectPrimitive } from "radix-ui"; +import type { FC } from "react"; +import type * as TypesGen from "#/api/typesGenerated"; +import { Alert, AlertDescription } from "#/components/Alert/Alert"; +import { Button } from "#/components/Button/Button"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "#/components/Select/Select"; +import type { ModelSelectorOption } from "./ChatElements"; +import { ModelOverrideAlerts } from "./ModelOverrideAlerts"; +import { SectionHeader } from "./SectionHeader"; + +type PersonalOverrideContext = TypesGen.ChatPersonalModelOverrideContext; +type PersonalOverrideMode = TypesGen.ChatPersonalModelOverrideMode; +type PersonalOverride = TypesGen.ChatPersonalModelOverride; +type UpdatePersonalOverrideRequest = + TypesGen.UpdateUserChatPersonalModelOverrideRequest; + +interface MutationCallbacks { + onSuccess?: () => void; + onError?: () => void; +} + +export type SavePersonalOverride = ( + req: UpdatePersonalOverrideRequest, + options?: MutationCallbacks, +) => void; + +interface PersonalOverrideFormValues { + mode: PersonalOverrideMode; + model_config_id: string; +} + +interface PersonalModelOverrideRowProps { + context: PersonalOverrideContext; + title: string; + description: string; + overrideData: PersonalOverride | undefined; + deploymentDefault?: TypesGen.ChatModelOverrideResponse; + modelOptions: readonly ModelSelectorOption[]; + modelConfigs: readonly TypesGen.ChatModelConfig[]; + modelConfigsError: unknown; + isLoading: boolean; + onSave: SavePersonalOverride; + isSaving: boolean; + isSaveError: boolean; + saveErrorMessage: string; + disabled: boolean; +} + +const getDefaultMode = ( + context: PersonalOverrideContext, +): PersonalOverrideMode => { + return context === "root" ? "chat_default" : "deployment_default"; +}; + +const toFormValues = ( + overrideData: PersonalOverride | undefined, + context: PersonalOverrideContext, +): PersonalOverrideFormValues => { + if (!overrideData || overrideData.is_malformed) { + return { mode: getDefaultMode(context), model_config_id: "" }; + } + return { + mode: overrideData.mode, + model_config_id: + overrideData.mode === "model" ? overrideData.model_config_id : "", + }; +}; + +const toUpdateRequest = ( + values: PersonalOverrideFormValues, +): UpdatePersonalOverrideRequest => { + if (values.mode === "model") { + return { + mode: "model", + model_config_id: values.model_config_id, + }; + } + return { mode: values.mode, model_config_id: "" }; +}; + +const getModelConfigLabel = (modelConfig: TypesGen.ChatModelConfig): string => { + return modelConfig.display_name.trim() || modelConfig.model || modelConfig.id; +}; + +const getModelOptionLabel = (option: ModelSelectorOption): string => { + return option.displayName.trim() || option.model || option.id; +}; + +const getModelConfigLabelByID = ( + modelConfigID: string, + modelConfigs: readonly TypesGen.ChatModelConfig[], +): string | undefined => { + const modelConfig = modelConfigs.find( + (config) => config.id === modelConfigID, + ); + return modelConfig ? getModelConfigLabel(modelConfig) : undefined; +}; + +const getUnavailableModelLabel = ( + modelConfigID: string, + modelConfigs: readonly TypesGen.ChatModelConfig[], +): string => { + const modelConfigLabel = getModelConfigLabelByID(modelConfigID, modelConfigs); + if (!modelConfigLabel) { + return `Unavailable model (${modelConfigID})`; + } + return `Unavailable: ${modelConfigLabel}`; +}; + +const getDefaultModeOptions = ( + context: PersonalOverrideContext, +): readonly Exclude[] => { + return context === "root" + ? ["chat_default"] + : ["deployment_default", "chat_default"]; +}; + +const getChatDefaultDescription = ( + context: PersonalOverrideContext, + modelConfigs: readonly TypesGen.ChatModelConfig[], +): string => { + if (context !== "root") { + return "Your current chat model"; + } + const defaultModel = modelConfigs.find((config) => config.is_default); + return defaultModel + ? getModelConfigLabel(defaultModel) + : "Model definition default"; +}; + +const getDeploymentDefaultDescription = ( + deploymentDefault: TypesGen.ChatModelOverrideResponse | undefined, + modelConfigs: readonly TypesGen.ChatModelConfig[], +): string => { + if (!deploymentDefault) { + return "Loading deployment default"; + } + if (deploymentDefault.is_malformed) { + return "Invalid deployment default"; + } + const modelConfigID = deploymentDefault.model_config_id.trim(); + if (modelConfigID === "") { + return "Chat default fallback"; + } + return ( + getModelConfigLabelByID(modelConfigID, modelConfigs) ?? + `Unavailable model (${modelConfigID})` + ); +}; + +const getSelectionLabel = ({ + context, + deploymentDefault, + isInvalidRootDeploymentDefault, + modelConfigs, + modelOptions, + values, +}: { + context: PersonalOverrideContext; + deploymentDefault?: TypesGen.ChatModelOverrideResponse; + isInvalidRootDeploymentDefault: boolean; + modelConfigs: readonly TypesGen.ChatModelConfig[]; + modelOptions: readonly ModelSelectorOption[]; + values: PersonalOverrideFormValues; +}): string => { + if (isInvalidRootDeploymentDefault) { + return "Invalid deployment default"; + } + + switch (values.mode) { + case "chat_default": + return `Chat default: ${getChatDefaultDescription(context, modelConfigs)}`; + case "deployment_default": + return `Deployment default: ${getDeploymentDefaultDescription( + deploymentDefault, + modelConfigs, + )}`; + case "model": { + const modelConfigID = values.model_config_id.trim(); + const modelOption = modelOptions.find( + (option) => option.id === modelConfigID, + ); + if (modelOption) { + return getModelOptionLabel(modelOption); + } + return modelConfigID === "" + ? "Select..." + : getUnavailableModelLabel(modelConfigID, modelConfigs); + } + } +}; + +const isDefaultModeOption = ( + value: string, +): value is Exclude => { + return value === "chat_default" || value === "deployment_default"; +}; + +// Local separator for use inside SelectContent. Defined here instead of +// in the core Select component so the styling stays scoped to this +// feature until a shared design lands. +const SelectSeparator: FC = () => ( + +); + +export const PersonalModelOverrideRow: FC = ({ + context, + title, + description, + overrideData, + deploymentDefault, + modelOptions, + modelConfigs, + modelConfigsError, + isLoading, + onSave, + isSaving, + isSaveError, + saveErrorMessage, + disabled, +}) => { + const hasLoadedOverride = overrideData !== undefined; + const isMalformedOverride = overrideData?.is_malformed ?? false; + const form = useFormik({ + enableReinitialize: true, + initialValues: toFormValues(overrideData, context), + onSubmit: (values, { resetForm }) => { + onSave(toUpdateRequest(values), { + onSuccess: () => resetForm({ values }), + }); + }, + }); + const isFormDisabled = + disabled || isSaving || isLoading || !hasLoadedOverride; + const canSave = + hasLoadedOverride && !disabled && (form.dirty || isMalformedOverride); + const defaultModeOptions = getDefaultModeOptions(context); + const isInvalidRootDeploymentDefault = + context === "root" && overrideData?.mode === "deployment_default"; + const isUnavailableSavedModel = + overrideData?.mode === "model" && + overrideData.is_set && + overrideData.model_config_id.trim() !== "" && + !modelOptions.some((option) => option.id === overrideData.model_config_id); + const isUnavailableSelectedModel = + form.values.mode === "model" && + form.values.model_config_id.trim() !== "" && + !modelOptions.some((option) => option.id === form.values.model_config_id); + const selectionValue = + form.values.mode === "model" + ? form.values.model_config_id + : form.values.mode; + const selectionLabel = getSelectionLabel({ + context, + deploymentDefault, + isInvalidRootDeploymentDefault, + modelConfigs, + modelOptions, + values: form.values, + }); + const canSaveSelection = + canSave && + (form.values.mode !== "model" || + (form.values.model_config_id.trim() !== "" && + !isUnavailableSelectedModel)); + + return ( +
+ +
+ + + {isInvalidRootDeploymentDefault && ( + + + The saved root override uses the deployment default, which is + not supported for root agents. Choose a valid value and save to + replace it. + + + )} + +
+ +
+ {isSaveError && ( +

+ {saveErrorMessage} +

+ )} +
+
+ ); +}; + +interface DefaultModeSelectItemProps { + mode: Exclude; + context: PersonalOverrideContext; + deploymentDefault?: TypesGen.ChatModelOverrideResponse; + modelConfigs: readonly TypesGen.ChatModelConfig[]; +} + +const DefaultModeSelectItem: FC = ({ + mode, + context, + deploymentDefault, + modelConfigs, +}) => { + const label = + mode === "deployment_default" ? "Deployment default" : "Chat default"; + const description = + mode === "deployment_default" + ? getDeploymentDefaultDescription(deploymentDefault, modelConfigs) + : getChatDefaultDescription(context, modelConfigs); + + return ( + + + {label} + + {description} + + + + ); +}; diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx index 11b5ba4fd3..cef02734a1 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx @@ -108,6 +108,7 @@ const meta: Meta = { isCreating: false, regeneratingTitleChatIds: [], archivedFilter: "active" as const, + isPersonalModelOverridesEnabled: true, onArchivedFilterChange: fn(), }, parameters: { @@ -273,7 +274,7 @@ export const RunningChatPreservesSpinner: Story = { await expect(spinner).toBeInTheDocument(); // The toggle button should exist (the node has children) but - // must be invisible by default — it only appears on hover of + // must be invisible by default. It only appears on hover of // the icon area itself, not the whole row. const toggle = canvas.getByTestId("agents-tree-toggle-root-running"); await expect(toggle).toBeInTheDocument(); @@ -1381,7 +1382,7 @@ export const WithUnreadChats: Story = { canvas.queryByTestId("unread-indicator-read-1"), ).not.toBeInTheDocument(); // Unread chat that IS the active chat should not show - // the indicator — the user is already viewing it. + // the indicator because the user is already viewing it. expect( canvas.queryByTestId("unread-indicator-unread-active"), ).not.toBeInTheDocument(); @@ -1649,6 +1650,126 @@ export const SettingsAPIKeysNonAdmin: Story = { ).toBeInTheDocument(); }, }; +export const SettingsUserAgentsNonAdmin: Story = { + args: { + chats: [], + isAdmin: false, + }, + parameters: { + queries: [ + { + key: userChatProviderConfigsKey, + data: [], + }, + ], + reactRouter: reactRouterParameters({ + location: { path: "/agents/settings/user-agents" }, + routing: settingsRouting, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const agentsLink = canvas.getByRole("link", { name: "Agents" }); + await expect(agentsLink).toHaveAttribute("aria-current", "page"); + expect( + canvas.queryByRole("link", { name: "Manage Agents" }), + ).not.toBeInTheDocument(); + }, +}; + +export const SettingsUserAgentsFeatureDisabled: Story = { + args: { + chats: [], + isAdmin: false, + isPersonalModelOverridesEnabled: false, + }, + parameters: { + queries: [ + { + key: userChatProviderConfigsKey, + data: [], + }, + ], + reactRouter: reactRouterParameters({ + location: { path: "/agents/settings/general" }, + routing: settingsRouting, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByRole("link", { name: "General" })).toBeInTheDocument(); + expect( + canvas.queryByRole("link", { name: "Agents" }), + ).not.toBeInTheDocument(); + }, +}; + +export const SettingsUserAgentsOverridesLoading: Story = { + args: { + chats: [], + isAdmin: false, + isPersonalModelOverridesEnabled: undefined, + }, + parameters: { + queries: [ + { + key: userChatProviderConfigsKey, + data: [], + }, + ], + reactRouter: reactRouterParameters({ + location: { path: "/agents/settings/general" }, + routing: settingsRouting, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByRole("link", { name: "General" })).toBeInTheDocument(); + expect( + canvas.queryByRole("link", { name: "Agents" }), + ).not.toBeInTheDocument(); + }, +}; + +export const SettingsUserAgentsAdmin: Story = { + args: { + chats: [], + isAdmin: true, + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/agents/settings/user-agents" }, + routing: settingsRouting, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const agentsLink = canvas.getByRole("link", { name: "Agents" }); + await expect(agentsLink).toHaveAttribute("aria-current", "page"); + expect( + canvas.getByRole("link", { name: "Manage Agents" }), + ).toBeInTheDocument(); + }, +}; + +export const SettingsAdminAgentsEntryPreserved: Story = { + args: { + chats: [], + isAdmin: true, + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/agents/settings/agents" }, + routing: settingsRouting, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const agentsLink = canvas.getByRole("link", { name: "Agents" }); + await expect(agentsLink).toHaveAttribute("aria-current", "page"); + expect(canvas.getByText("Manage Agents")).toBeInTheDocument(); + }, +}; export const PreservesArchivedFilterOnSettingsNavigation: Story = { args: { diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx index c13065ab16..fdf4fe00b5 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx @@ -185,6 +185,7 @@ interface AgentsSidebarProps { archivedFilter: "active" | "archived"; onArchivedFilterChange?: (filter: "active" | "archived") => void; onCollapse?: () => void; + isPersonalModelOverridesEnabled?: boolean; isAdmin?: boolean; } @@ -842,6 +843,7 @@ export const AgentsSidebar: FC = (props) => { archivedFilter, onArchivedFilterChange, onCollapse, + isPersonalModelOverridesEnabled = false, isAdmin = false, } = props; const { agentId, chatId } = useParams<{ @@ -889,7 +891,7 @@ export const AgentsSidebar: FC = (props) => { .filter((chat): chat is Chat => (chat?.pin_order ?? 0) > 0) .sort((a, b) => a.pin_order - b.pin_order); - // Local override for pinned order during drag — applied + // Local override for pinned order during drag. Applied // synchronously so there's no flash between the dnd-kit // transform clearing and the server data arriving. const [localPinOrder, setLocalPinOrder] = useState(null); @@ -1012,8 +1014,8 @@ export const AgentsSidebar: FC = (props) => { ); // Auto-expand ancestors of the active chat so it's always visible. - // Only runs when activeChatId changes — not on every parentById - // recalculation — so user-initiated collapse is preserved. + // Only runs when activeChatId changes, not on every parentById + // recalculation, so user-initiated collapse is preserved. const parentByIdRef = useRef(chatTree.parentById); useEffect(() => { parentByIdRef.current = chatTree.parentById; @@ -1386,6 +1388,15 @@ export const AgentsSidebar: FC = (props) => { to="/agents/settings/general" state={location.state} /> + {isPersonalModelOverridesEnabled && ( + + )} void; @@ -69,6 +69,7 @@ export const SubagentModelOverrideSettings: FC< disabled = false, }) => { const hasLoadedModelOverride = modelOverrideData !== undefined; + const isMalformedOverride = modelOverrideData?.is_malformed ?? false; const enabledModelOptions = enabledModelConfigs.map(toModelSelectorOption); const form = useFormik({ @@ -89,17 +90,16 @@ export const SubagentModelOverrideSettings: FC< ); }, }); + const isFormDisabled = + disabled || isSaving || isLoading || !hasLoadedModelOverride; + const canSave = + hasLoadedModelOverride && !disabled && (form.dirty || isMalformedOverride); const isUnavailableSavedModel = form.values.model_config_id !== "" && !enabledModelOptions.some( (option) => option.id === form.values.model_config_id, ); - const isMalformedOverride = modelOverrideData?.is_malformed ?? false; - const isModelOverrideDisabled = - disabled || isSaving || isLoading || !hasLoadedModelOverride; - const canSaveModelOverride = - hasLoadedModelOverride && (form.dirty || isMalformedOverride); return (
@@ -119,7 +119,7 @@ export const SubagentModelOverrideSettings: FC< options={enabledModelOptions} value={form.values.model_config_id} onValueChange={(value) => form.setFieldValue("model_config_id", value)} - disabled={isModelOverrideDisabled} + disabled={isFormDisabled} placeholder={ isUnavailableSavedModel ? "Unavailable model" : unsetPlaceholder } @@ -129,24 +129,13 @@ export const SubagentModelOverrideSettings: FC< className="h-10 w-full justify-between rounded-md border border-border border-solid bg-transparent px-3 text-sm shadow-sm" contentClassName="min-w-[18rem]" /> - {isUnavailableSavedModel && ( - - {unavailableModelWarning} - - )} - {isMalformedOverride && ( - - - The saved override is malformed and is being treated as unset. Click - Save to clear it. - - - )} - {Boolean(modelConfigsError) && ( -

- Failed to load model configs. -

- )} +
-
diff --git a/site/src/router.tsx b/site/src/router.tsx index d6dcd4e95a..92aca6644c 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -375,6 +375,9 @@ const AgentSettingsLifecyclePage = lazy( const AgentSettingsAgentsPage = lazy( () => import("./pages/AgentsPage/AgentSettingsAgentsPage"), ); +const AgentSettingsUserAgentsPage = lazy( + () => import("./pages/AgentsPage/AgentSettingsUserAgentsPage"), +); const AgentSettingsProvidersPage = lazy( () => import("./pages/AgentsPage/AgentSettingsProvidersPage"), ); @@ -740,6 +743,10 @@ export const router = createBrowserRouter( element={} /> } /> + } + /> } /> } /> } />