feat(site): add personal model override settings UI (#24748)

Adds the UI for personal chat model overrides for root chats, General
subagents, and Explore subagents. Backend support landed in #24715, and
this PR now targets `main`.

## Summary

- Add the admin switch for enabling user personal model overrides.
- Add the user `Agents` settings page at `/agents/settings/user-agents`.
- Use one dropdown per context with pinned chat default and deployment
default options.
- Show the resolved deployment default model in personal settings when
available.
- Teach root chat creation to honor saved root preferences without
replacing explicit user selections.
- Add shared unavailable and malformed override alerts, select separator
support, and Storybook coverage.

## Testing

- `pnpm --dir site lint:types`
- `pnpm --dir site check`
- `pnpm --dir site test:storybook
src/pages/AgentsPage/AgentSettingsUserAgentsPageView.stories.tsx
src/pages/AgentsPage/components/AdminPersonalModelOverridesSettings.stories.tsx
src/pages/AgentsPage/components/AgentCreateForm.stories.tsx
src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx`

> Mux is working on behalf of Mike.
This commit is contained in:
Michael Suchacz
2026-05-05 13:11:59 +02:00
committed by GitHub
parent 0c5a25c018
commit 1e7874c2c1
24 changed files with 2254 additions and 74 deletions
+19
View File
@@ -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
+37
View File
@@ -3281,6 +3281,24 @@ class ExperimentalApiMethods {
);
};
getChatPersonalModelOverridesAdminSettings =
async (): Promise<TypesGen.ChatPersonalModelOverridesAdminSettings> => {
const response =
await this.axios.get<TypesGen.ChatPersonalModelOverridesAdminSettings>(
"/api/experimental/chats/config/personal-model-overrides",
);
return response.data;
};
updateChatPersonalModelOverridesAdminSettings = async (
req: TypesGen.UpdateChatPersonalModelOverridesAdminSettingsRequest,
): Promise<void> => {
await this.axios.put(
"/api/experimental/chats/config/personal-model-overrides",
req,
);
};
getChatDebugLogging =
async (): Promise<TypesGen.ChatDebugLoggingAdminSettings> => {
const response =
@@ -3314,6 +3332,25 @@ class ExperimentalApiMethods {
);
};
getUserChatPersonalModelOverrides =
async (): Promise<TypesGen.UserChatPersonalModelOverridesResponse> => {
const response =
await this.axios.get<TypesGen.UserChatPersonalModelOverridesResponse>(
"/api/experimental/chats/config/user-personal-model-overrides",
);
return response.data;
};
updateUserChatPersonalModelOverride = async (
context: TypesGen.ChatPersonalModelOverrideContext,
req: TypesGen.UpdateUserChatPersonalModelOverrideRequest,
): Promise<void> => {
await this.axios.put(
`/api/experimental/chats/config/user-personal-model-overrides/${encodeURIComponent(context)}`,
req,
);
};
getChatDebugRuns = async (
chatId: string,
): Promise<TypesGen.ChatDebugRunSummary[]> => {
+54
View File
@@ -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<TypesGen.UserChatPersonalModelOverridesResponse> =>
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;
+16 -9
View File
@@ -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}
@@ -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 (
<RequirePermission isFeatureVisible={canEditDeploymentConfig}>
<AgentSettingsAgentsPageView
adminOverridesData={personalModelOverridesAdminSettingsQuery.data}
adminOverridesError={personalModelOverridesAdminSettingsQuery.error}
onRetryAdminOverrides={() => {
void personalModelOverridesAdminSettingsQuery.refetch();
}}
isRetryingAdminOverrides={
personalModelOverridesAdminSettingsQuery.isFetching
}
onSaveAdminOverrides={
savePersonalModelOverridesAdminSettingsMutation.mutate
}
isSavingAdminOverrides={
savePersonalModelOverridesAdminSettingsMutation.isPending
}
isSaveAdminOverridesError={
savePersonalModelOverridesAdminSettingsMutation.isError
}
generalModelOverrideData={generalModelOverrideQuery.data}
titleGenerationModelOverrideData={titleGenerationModelQuery.data}
exploreModelOverrideData={exploreModelOverrideQuery.data}
@@ -109,6 +109,13 @@ const allModelConfigs: TypesGen.ChatModelConfig[] = [
const makeArgs = (
overrides: Partial<AgentSettingsAgentsPageViewProps> = {},
): 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", {
@@ -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."
/>
<AdminPersonalModelOverridesSettings
adminSettings={adminOverridesData}
adminSettingsError={adminOverridesError}
onRetryAdminSettings={onRetryAdminOverrides}
isRetryingAdminSettings={isRetryingAdminOverrides}
onSaveAdminSetting={onSaveAdminOverrides}
isSavingAdminSetting={isSavingAdminOverrides}
isSaveAdminSettingError={isSaveAdminOverridesError}
/>
{showGeneralModelSection && onSaveGeneralModelOverride && (
<section aria-label="General model" className="flex flex-col gap-3">
<SectionHeader
@@ -0,0 +1,82 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatModelConfigs,
chatModels,
updateUserChatPersonalModelOverride,
userChatPersonalModelOverrides,
} from "#/api/queries/chats";
import type * as TypesGen from "#/api/typesGenerated";
import { AgentSettingsUserAgentsPageView } from "./AgentSettingsUserAgentsPageView";
import { getModelOptionsFromConfigs } from "./utils/modelOptions";
const AgentSettingsUserAgentsPage: FC = () => {
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 (
<AgentSettingsUserAgentsPageView
overridesData={overridesQuery.data}
overridesError={overridesQuery.error}
onRetryOverrides={() => {
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;
@@ -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> = {},
): 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> = {},
): 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> = {},
): TypesGen.ChatModelOverrideResponse => ({
context,
model_config_id: "",
is_malformed: false,
...overrides,
});
const buildDeploymentDefaults = (
overrides: Partial<TypesGen.ChatPersonalModelOverrideDeploymentDefaults> = {},
): 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> = {},
): 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> = {},
): 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<HTMLElement> => {
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<typeof AgentSettingsUserAgentsPageView>;
export default meta;
type Story = StoryObj<typeof AgentSettingsUserAgentsPageView>;
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(),
);
});
},
};
@@ -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 (
<div className="flex flex-col gap-8">
<SectionHeader
label="Agents"
description="Choose personal model defaults for root agents and delegated agents."
/>
{overridesError ? (
<div className="flex flex-col gap-2">
<ErrorAlert error={overridesError} />
{onRetryOverrides && (
<Button
disabled={isRetryingOverrides}
onClick={onRetryOverrides}
size="sm"
type="button"
variant="outline"
>
Retry
</Button>
)}
</div>
) : null}
{!personalOverridesEnabled && (
<Alert severity="info">
<AlertDescription>
Personal model overrides are disabled by an administrator. Saved
values are shown for reference, but changes cannot be saved.
</AlertDescription>
</Alert>
)}
<PersonalModelOverrideRow
context="root"
title="Root agent model"
description="Choose the model behavior for new root agents."
overrideData={overridesData?.root}
modelOptions={modelOptions}
modelConfigs={modelConfigs}
modelConfigsError={modelConfigsError}
isLoading={isLoading}
onSave={onSaveRootModelOverride}
isSaving={isSavingRootModelOverride}
isSaveError={isSaveRootModelOverrideError}
saveErrorMessage="Failed to save root agent model override."
disabled={isDisabled}
/>
<PersonalModelOverrideRow
context="general"
title="General subagent model"
description="Choose the model behavior for delegated agents with write capabilities."
overrideData={overridesData?.general}
deploymentDefault={overridesData?.deployment_defaults.general}
modelOptions={modelOptions}
modelConfigs={modelConfigs}
modelConfigsError={modelConfigsError}
isLoading={isLoading}
onSave={onSaveGeneralModelOverride}
isSaving={isSavingGeneralModelOverride}
isSaveError={isSaveGeneralModelOverrideError}
saveErrorMessage="Failed to save general subagent model override."
disabled={isDisabled}
/>
<PersonalModelOverrideRow
context="explore"
title="Explore subagent model"
description="Choose the model behavior for read-only Explore subagents."
overrideData={overridesData?.explore}
deploymentDefault={overridesData?.deployment_defaults.explore}
modelOptions={modelOptions}
modelConfigs={modelConfigs}
modelConfigsError={modelConfigsError}
isLoading={isLoading}
onSave={onSaveExploreModelOverride}
isSaving={isSavingExploreModelOverride}
isSaveError={isSaveExploreModelOverrideError}
saveErrorMessage="Failed to save Explore subagent model override."
disabled={isDisabled}
/>
</div>
);
};
+9 -2
View File
@@ -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<string, ChatDetailError>
>({});
@@ -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()}
@@ -155,6 +155,10 @@ const fixedNow = dayjs("2026-03-12T12:00:00");
const AgentsRouteElement = () => (
<AgentSettingsAgentsPageView
adminOverridesData={{ allow_users: false }}
onSaveAdminOverrides={fn()}
isSavingAdminOverrides={false}
isSaveAdminOverridesError={false}
exploreModelOverrideData={{
context: "explore",
model_config_id: "",
@@ -67,6 +67,7 @@ interface AgentsPageViewProps {
onRenameTitle: (chatId: string, title: string) => Promise<void>;
regeneratingTitleChatIds: readonly string[];
onToggleSidebarCollapsed: () => void;
isPersonalModelOverridesEnabled?: boolean;
isAgentsAdmin: boolean;
hasNextPage: boolean | undefined;
onLoadMore: () => void;
@@ -104,6 +105,7 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
onRenameTitle,
regeneratingTitleChatIds,
onToggleSidebarCollapsed,
isPersonalModelOverridesEnabled,
isAgentsAdmin,
hasNextPage,
onLoadMore,
@@ -194,6 +196,7 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
archivedFilter={archivedFilter}
onArchivedFilterChange={onArchivedFilterChange}
onCollapse={onCollapseSidebar}
isPersonalModelOverridesEnabled={isPersonalModelOverridesEnabled}
isAdmin={isAgentsAdmin}
/>
</div>
@@ -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<typeof AdminPersonalModelOverridesSettings>;
export default meta;
type Story = StoryObj<typeof AdminPersonalModelOverridesSettings>;
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(),
);
});
},
};
@@ -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 (
<form
aria-label="Personal model overrides"
className="space-y-2"
onSubmit={form.handleSubmit}
>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<h3 className="m-0 text-sm font-semibold text-content-primary">
Enable users to define their personal overrides
</h3>
<p className="m-0 text-xs text-content-secondary">
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.
</p>
</div>
<Switch
checked={form.values.allow_users}
onCheckedChange={(checked) => {
void form.setFieldValue("allow_users", checked);
}}
aria-label="Enable users to define their personal overrides"
type="button"
disabled={isDisabled}
/>
</div>
{hasAdminSettingsError ? (
<div className="flex flex-col gap-2">
<ErrorAlert error={adminSettingsError} />
{onRetryAdminSettings && (
<Button
disabled={isRetryingAdminSettings}
onClick={onRetryAdminSettings}
size="sm"
type="button"
variant="outline"
>
Retry
</Button>
)}
</div>
) : (
!hasLoadedAdminSettings && (
<p className="m-0 text-xs text-content-secondary">
Loading personal model override settings...
</p>
)
)}
<div className="flex justify-end gap-2">
<Button size="sm" type="submit" disabled={isDisabled || !form.dirty}>
Save
</Button>
</div>
{isSaveAdminSettingError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save personal model override settings.
</p>
)}
</form>
);
};
@@ -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> = {},
): 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> = {},
): 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<string, boolean>) => {
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<typeof fn>;
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,
@@ -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<AgentCreateFormProps> = ({
modelConfigs,
isModelCatalogLoading,
isModelConfigsLoading,
rootPersonalModelOverride,
isPersonalModelOverridesLoading = false,
mcpServers,
onMCPAuthComplete,
workspaceCount: _workspaceCount,
@@ -161,6 +165,10 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
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<AgentCreateFormProps> = ({
? 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<string | null>(
@@ -316,7 +352,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
message,
fileIDs,
workspaceId: effectiveWorkspaceId ?? undefined,
model: selectedModel || undefined,
model: submittedModel,
organizationId,
mcpServerIds:
effectiveMCPServerIds.length > 0
@@ -384,7 +420,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
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<AgentCreateFormProps> = ({
<AgentChatInput
onSend={handleSendWithAttachments}
placeholder="Ask Coder to build, fix bugs, or explore your project..."
isDisabled={isCreating || isForbidden}
isDisabled={
isCreating || isForbidden || isPersonalModelOverridesLoading
}
isLoading={isCreating}
initialValue={initialInputValue}
initialEditorState={initialEditorState}
@@ -58,18 +58,6 @@ const formatContextLimit = (tokens: number): string => {
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<ModelSelectorProps> = ({
options,
value,
@@ -113,7 +101,7 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
onOpenChange={onOpenChange}
>
<SelectTrigger
aria-label={selectedModel ? getOptionLabel(selectedModel) : placeholder}
aria-label={selectedModel ? selectedModel.displayName : placeholder}
className={cn(
"h-8 min-w-0 shrink md:shrink-0 md:w-auto gap-0.5 md:gap-1.5 border-0 bg-transparent px-1 text-xs shadow-none transition-colors hover:bg-transparent hover:text-content-primary focus:ring-0 [&>span]:truncate [&>svg]:shrink-0 [&>svg]:transition-colors [&>svg]:hover:text-content-primary",
className,
@@ -121,7 +109,7 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
onTouchStart={onTriggerTouchStart}
>
<SelectValue placeholder={placeholder}>
{selectedModel ? getOptionLabel(selectedModel) : placeholder}
{selectedModel ? selectedModel.displayName : placeholder}
</SelectValue>
</SelectTrigger>
<SelectContent
@@ -172,7 +160,7 @@ const ModelOptionItem: FC<ModelOptionItemProps> = ({
providerLabel,
isSelected,
}) => {
const label = getOptionLabel(option);
const label = option.displayName;
const contextInfo =
option.contextLimit != null && option.contextLimit > 0
? formatContextLimit(option.contextLimit)
@@ -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<ModelOverrideAlertsProps> = ({
isUnavailableSavedModel,
unavailableMessage,
isMalformedOverride,
malformedMessage,
modelConfigsError,
children,
}) => {
return (
<>
{isUnavailableSavedModel && (
<Alert severity="warning">
<AlertDescription>{unavailableMessage}</AlertDescription>
</Alert>
)}
{isMalformedOverride && (
<Alert severity="warning">
<AlertDescription>{malformedMessage}</AlertDescription>
</Alert>
)}
{children}
{Boolean(modelConfigsError) && (
<p className="m-0 text-xs text-content-destructive">
Failed to load model configs.
</p>
)}
</>
);
};
@@ -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<PersonalOverrideMode, "model">[] => {
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<PersonalOverrideMode, "model"> => {
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 = () => (
<SelectPrimitive.Separator className="-mx-1 my-1 h-px bg-border" />
);
export const PersonalModelOverrideRow: FC<PersonalModelOverrideRowProps> = ({
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<PersonalOverrideFormValues>({
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 (
<section aria-label={title} className="flex flex-col gap-3">
<SectionHeader label={title} description={description} level="section" />
<form className="flex flex-col gap-3" onSubmit={form.handleSubmit}>
<Select
value={selectionValue}
onValueChange={(value) => {
if (isDefaultModeOption(value)) {
void form.setValues({ mode: value, model_config_id: "" });
return;
}
void form.setValues({ mode: "model", model_config_id: value });
}}
disabled={isFormDisabled}
>
<SelectTrigger
aria-label={`${title} behavior`}
className="h-10 w-full justify-between rounded-md border border-border border-solid bg-transparent px-3 text-sm shadow-sm md:w-[18rem]"
>
<SelectValue placeholder="Select...">{selectionLabel}</SelectValue>
</SelectTrigger>
<SelectContent className="min-w-[18rem]">
{isInvalidRootDeploymentDefault && (
<>
<SelectItem value="deployment_default" disabled>
Invalid deployment default
</SelectItem>
<SelectSeparator />
</>
)}
<SelectGroup>
{defaultModeOptions.map((mode) => (
<DefaultModeSelectItem
key={mode}
mode={mode}
context={context}
deploymentDefault={deploymentDefault}
modelConfigs={modelConfigs}
/>
))}
</SelectGroup>
<SelectSeparator />
{isUnavailableSelectedModel && (
<>
<SelectItem value={form.values.model_config_id} disabled>
{getUnavailableModelLabel(
form.values.model_config_id,
modelConfigs,
)}
</SelectItem>
<SelectSeparator />
</>
)}
<SelectGroup>
{modelOptions.map((option) => (
<SelectItem key={option.id} value={option.id}>
{getModelOptionLabel(option)}
</SelectItem>
))}
{modelOptions.length === 0 && (
<SelectItem value="__empty_models__" disabled>
{isLoading ? "Loading models..." : "No enabled models found."}
</SelectItem>
)}
</SelectGroup>
</SelectContent>
</Select>
<ModelOverrideAlerts
isUnavailableSavedModel={isUnavailableSavedModel}
unavailableMessage="The saved model is unavailable and will be ignored until you choose a valid model override."
isMalformedOverride={isMalformedOverride}
malformedMessage="The saved override is malformed. Choose a valid value and save to replace it."
modelConfigsError={modelConfigsError}
>
{isInvalidRootDeploymentDefault && (
<Alert severity="warning">
<AlertDescription>
The saved root override uses the deployment default, which is
not supported for root agents. Choose a valid value and save to
replace it.
</AlertDescription>
</Alert>
)}
</ModelOverrideAlerts>
<div className="flex justify-end">
<Button
size="sm"
type="submit"
disabled={isFormDisabled || !canSaveSelection}
>
Save
</Button>
</div>
{isSaveError && (
<p className="m-0 text-xs text-content-destructive">
{saveErrorMessage}
</p>
)}
</form>
</section>
);
};
interface DefaultModeSelectItemProps {
mode: Exclude<PersonalOverrideMode, "model">;
context: PersonalOverrideContext;
deploymentDefault?: TypesGen.ChatModelOverrideResponse;
modelConfigs: readonly TypesGen.ChatModelConfig[];
}
const DefaultModeSelectItem: FC<DefaultModeSelectItemProps> = ({
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 (
<SelectItem value={mode}>
<span className="flex min-w-0 flex-col">
<span className="truncate text-content-primary">{label}</span>
<span className="truncate text-content-secondary text-xs leading-tight">
{description}
</span>
</span>
</SelectItem>
);
};
@@ -108,6 +108,7 @@ const meta: Meta<typeof AgentsSidebar> = {
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: {
@@ -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<AgentsSidebarProps> = (props) => {
archivedFilter,
onArchivedFilterChange,
onCollapse,
isPersonalModelOverridesEnabled = false,
isAdmin = false,
} = props;
const { agentId, chatId } = useParams<{
@@ -889,7 +891,7 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (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<string[] | null>(null);
@@ -1012,8 +1014,8 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (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<AgentsSidebarProps> = (props) => {
to="/agents/settings/general"
state={location.state}
/>
{isPersonalModelOverridesEnabled && (
<SettingsNavItem
icon={BotIcon}
label="Agents"
active={settingsSection === "user-agents"}
to="/agents/settings/user-agents"
state={location.state}
/>
)}
<SettingsNavItem
icon={ShrinkIcon}
label="Compaction"
@@ -1604,7 +1615,7 @@ const LoadMoreSentinel: FC<{
// Don't observe while a fetch is in progress. When the
// fetch completes this effect re-runs, creating a fresh
// observer whose initial entry detects the sentinel if
// it's still visible — fixing the case where loaded items
// it's still visible, fixing the case where loaded items
// don't push the sentinel out of view and the previous
// observer never re-fires.
if (isFetchingNextPage) return;
@@ -1,10 +1,10 @@
import { useFormik } from "formik";
import type { FC, ReactNode } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert, AlertDescription } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import type { ModelSelectorOption } from "./ChatElements/ModelSelector";
import { ModelSelector } from "./ChatElements/ModelSelector";
import { ModelOverrideAlerts } from "./ModelOverrideAlerts";
export interface MutationCallbacks {
onSuccess?: () => 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 (
<form aria-label={title} className="space-y-2" onSubmit={form.handleSubmit}>
@@ -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 && (
<Alert severity="warning">
<AlertDescription>{unavailableModelWarning}</AlertDescription>
</Alert>
)}
{isMalformedOverride && (
<Alert severity="warning">
<AlertDescription>
The saved override is malformed and is being treated as unset. Click
Save to clear it.
</AlertDescription>
</Alert>
)}
{Boolean(modelConfigsError) && (
<p className="m-0 text-xs text-content-destructive">
Failed to load model configs.
</p>
)}
<ModelOverrideAlerts
isUnavailableSavedModel={isUnavailableSavedModel}
unavailableMessage={unavailableModelWarning}
isMalformedOverride={isMalformedOverride}
malformedMessage="The saved override is malformed and is being treated as unset. Click Save to clear it."
modelConfigsError={modelConfigsError}
/>
<div className="flex justify-end gap-2">
<Button
size="sm"
@@ -155,15 +144,11 @@ export const SubagentModelOverrideSettings: FC<
onClick={() => {
form.setFieldValue("model_config_id", "");
}}
disabled={isModelOverrideDisabled}
disabled={isFormDisabled}
>
Clear
</Button>
<Button
size="sm"
type="submit"
disabled={isModelOverrideDisabled || !canSaveModelOverride}
>
<Button size="sm" type="submit" disabled={isFormDisabled || !canSave}>
Save
</Button>
</div>
+7
View File
@@ -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={<AgentSettingsExperimentsPage />}
/>
<Route path="lifecycle" element={<AgentSettingsLifecyclePage />} />
<Route
path="user-agents"
element={<AgentSettingsUserAgentsPage />}
/>
<Route path="admin" element={<AgentSettingsAgentsPage />} />
<Route path="agents" element={<AgentSettingsAgentsPage />} />
<Route path="api-keys" element={<AgentSettingsAPIKeysPage />} />