mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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[]> => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
Reference in New Issue
Block a user