- (!allowCustomValue || !nodeStyle) &&
- !disabled &&
- setShowOptions(true)
- }
+ className={getAnchorClassName(editNode, disabled, isFocused)}
+ onClick={() => !nodeStyle && !disabled && setShowOptions(true)}
+ role="button"
+ tabIndex={disabled ? -1 : 0}
+ aria-disabled={disabled}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ if (!nodeStyle && !disabled) {
+ if (e.key === " ") {
+ e.preventDefault();
+ }
+ setShowOptions(true);
+ }
+ }
+ }}
>
{!disabled && selectedOptions?.length > 0 ? (
handleAuthFieldChange(
- "oauthCallbackPath",
+ "oauthCallbackUrl",
e.target.value,
)
}
diff --git a/src/frontend/src/modals/modelProviderModal/__tests__/ModelProviderActive.test.tsx b/src/frontend/src/modals/modelProviderModal/__tests__/ModelProviderActive.test.tsx
new file mode 100644
index 000000000000..c484bd8d4a71
--- /dev/null
+++ b/src/frontend/src/modals/modelProviderModal/__tests__/ModelProviderActive.test.tsx
@@ -0,0 +1,134 @@
+import { render, screen } from "@testing-library/react";
+import ModelProviderActive from "../components/ModelProviderActive";
+
+// Mock ForwardedIconComponent
+jest.mock("@/components/common/genericIconComponent", () => ({
+ __esModule: true,
+ default: ({ name, className }: { name: string; className?: string }) => (
+
+ {name}
+
+ ),
+}));
+
+describe("ModelProviderActive", () => {
+ describe("Rendering", () => {
+ it("should render the component container", () => {
+ render(
);
+
+ expect(screen.getByTestId("model-provider-active")).toBeInTheDocument();
+ expect(screen.getByText("Models")).toBeInTheDocument();
+ });
+
+ it("should render LLM badges when activeLLMs is provided", () => {
+ const activeLLMs = ["gpt-4", "gpt-3.5-turbo"];
+
+ render(
+
,
+ );
+
+ expect(screen.getByText("LLM")).toBeInTheDocument();
+ expect(screen.getByTestId("active-llm-badge-gpt-4")).toBeInTheDocument();
+ expect(
+ screen.getByTestId("active-llm-badge-gpt-3.5-turbo"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("gpt-4")).toBeInTheDocument();
+ expect(screen.getByText("gpt-3.5-turbo")).toBeInTheDocument();
+ });
+
+ it("should render embedding badges when activeEmbeddings is provided", () => {
+ const activeEmbeddings = ["text-embedding-ada-002", "embed-large"];
+
+ render(
+
,
+ );
+
+ expect(screen.getByText("Embeddings")).toBeInTheDocument();
+ expect(
+ screen.getByTestId("active-embedding-badge-text-embedding-ada-002"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("active-embedding-badge-embed-large"),
+ ).toBeInTheDocument();
+ });
+
+ it("should render both LLM and embedding sections when both are provided", () => {
+ const activeLLMs = ["gpt-4"];
+ const activeEmbeddings = ["text-embedding-ada-002"];
+
+ render(
+
,
+ );
+
+ expect(screen.getByText("LLM")).toBeInTheDocument();
+ expect(screen.getByText("Embeddings")).toBeInTheDocument();
+ expect(screen.getByTestId("active-llm-badge-gpt-4")).toBeInTheDocument();
+ expect(
+ screen.getByTestId("active-embedding-badge-text-embedding-ada-002"),
+ ).toBeInTheDocument();
+ });
+
+ it("should not render LLM section when activeLLMs is empty", () => {
+ render(
+
,
+ );
+
+ expect(screen.queryByText("LLM")).not.toBeInTheDocument();
+ expect(screen.getByText("Embeddings")).toBeInTheDocument();
+ });
+
+ it("should not render Embeddings section when activeEmbeddings is empty", () => {
+ render(
+
,
+ );
+
+ expect(screen.getByText("LLM")).toBeInTheDocument();
+ expect(screen.queryByText("Embeddings")).not.toBeInTheDocument();
+ });
+
+ it("should render info icon", () => {
+ render(
);
+
+ expect(screen.getByTestId("icon-info")).toBeInTheDocument();
+ });
+ });
+
+ describe("Multiple Models", () => {
+ it("should render multiple LLM badges", () => {
+ const activeLLMs = ["model-1", "model-2", "model-3"];
+
+ render(
+
,
+ );
+
+ activeLLMs.forEach((model) => {
+ expect(
+ screen.getByTestId(`active-llm-badge-${model}`),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("should render multiple embedding badges", () => {
+ const activeEmbeddings = ["embed-1", "embed-2", "embed-3"];
+
+ render(
+
,
+ );
+
+ activeEmbeddings.forEach((model) => {
+ expect(
+ screen.getByTestId(`active-embedding-badge-${model}`),
+ ).toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/src/frontend/src/modals/modelProviderModal/__tests__/ModelProviderEdit.test.tsx b/src/frontend/src/modals/modelProviderModal/__tests__/ModelProviderEdit.test.tsx
new file mode 100644
index 000000000000..b98db7cc3cbb
--- /dev/null
+++ b/src/frontend/src/modals/modelProviderModal/__tests__/ModelProviderEdit.test.tsx
@@ -0,0 +1,151 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import ModelProviderEdit from "../components/ModelProviderEdit";
+
+// Mock ForwardedIconComponent
+jest.mock("@/components/common/genericIconComponent", () => ({
+ __esModule: true,
+ default: ({ name, className }: { name: string; className?: string }) => (
+
+ {name}
+
+ ),
+}));
+
+// Mock PROVIDER_VARIABLE_MAPPING
+jest.mock("@/constants/providerConstants", () => ({
+ PROVIDER_VARIABLE_MAPPING: {
+ OpenAI: "OPENAI_API_KEY",
+ Anthropic: "ANTHROPIC_API_KEY",
+ Cohere: "COHERE_API_KEY",
+ },
+}));
+
+const defaultProps = {
+ authName: "",
+ onAuthNameChange: jest.fn(),
+ apiKey: "",
+ onApiKeyChange: jest.fn(),
+ apiBase: "",
+ onApiBaseChange: jest.fn(),
+};
+
+describe("ModelProviderEdit", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("Rendering", () => {
+ it("should render the component container", () => {
+ render(
);
+
+ expect(screen.getByTestId("model-provider-edit")).toBeInTheDocument();
+ });
+
+ it("should render Authorization Name input", () => {
+ render(
);
+
+ // Check for the label (may have multiple due to placeholder)
+ expect(screen.getByTestId("auth-name-input")).toBeInTheDocument();
+ });
+
+ it("should render API Key input with required indicator", () => {
+ render(
);
+
+ expect(screen.getByText("API Key")).toBeInTheDocument();
+ expect(screen.getByText("*")).toBeInTheDocument();
+ expect(screen.getByTestId("api-key-input")).toBeInTheDocument();
+ });
+
+ it("should render API Base input", () => {
+ render(
);
+
+ expect(screen.getByText("API Base")).toBeInTheDocument();
+ expect(screen.getByTestId("api-base-input")).toBeInTheDocument();
+ });
+
+ it("should render Find your API key link", () => {
+ render(
);
+
+ expect(screen.getByText(/Find your API key/)).toBeInTheDocument();
+ });
+
+ it("should display provider-specific variable name when providerName is set", () => {
+ render(
);
+
+ const authInput = screen.getByTestId("auth-name-input");
+ expect(authInput).toHaveValue("OPENAI_API_KEY");
+ });
+
+ it("should display UNKNOWN_API_KEY when providerName is not set", () => {
+ render(
);
+
+ const authInput = screen.getByTestId("auth-name-input");
+ expect(authInput).toHaveValue("UNKNOWN_API_KEY");
+ });
+ });
+
+ describe("Input Interactions", () => {
+ it("should call onApiKeyChange when API key is entered", async () => {
+ const onApiKeyChange = jest.fn();
+ const user = userEvent.setup();
+
+ render(
+
,
+ );
+
+ const apiKeyInput = screen.getByTestId("api-key-input");
+ await user.type(apiKeyInput, "sk-test-key");
+
+ expect(onApiKeyChange).toHaveBeenCalled();
+ });
+
+ it("should call onApiBaseChange when API base is entered", async () => {
+ const onApiBaseChange = jest.fn();
+ const user = userEvent.setup();
+
+ render(
+
,
+ );
+
+ const apiBaseInput = screen.getByTestId("api-base-input");
+ await user.type(apiBaseInput, "https://api.example.com");
+
+ expect(onApiBaseChange).toHaveBeenCalled();
+ });
+
+ it("should display provided values in inputs", () => {
+ render(
+
,
+ );
+
+ expect(screen.getByTestId("api-key-input")).toHaveValue("my-api-key");
+ expect(screen.getByTestId("api-base-input")).toHaveValue(
+ "https://custom.api.com",
+ );
+ });
+ });
+
+ describe("Input States", () => {
+ it("should have auth name input disabled", () => {
+ render(
);
+
+ const authInput = screen.getByTestId("auth-name-input");
+ expect(authInput).toBeDisabled();
+ });
+
+ it("should have API key input as password type", () => {
+ render(
);
+
+ const apiKeyInput = screen.getByTestId("api-key-input");
+ expect(apiKeyInput).toHaveAttribute("type", "password");
+ });
+ });
+});
diff --git a/src/frontend/src/modals/modelProviderModal/__tests__/ModelSelection.test.tsx b/src/frontend/src/modals/modelProviderModal/__tests__/ModelSelection.test.tsx
new file mode 100644
index 000000000000..fc70f139b1b5
--- /dev/null
+++ b/src/frontend/src/modals/modelProviderModal/__tests__/ModelSelection.test.tsx
@@ -0,0 +1,176 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import ModelSelection from "../components/ModelSelection";
+import { Model } from "../components/types";
+
+// Mock ForwardedIconComponent
+jest.mock("@/components/common/genericIconComponent", () => ({
+ __esModule: true,
+ default: ({ name, className }: { name: string; className?: string }) => (
+
+ {name}
+
+ ),
+}));
+
+// Mock enabled models hook
+const mockEnabledModels = {
+ enabled_models: {
+ OpenAI: {
+ "gpt-4": true,
+ "gpt-3.5-turbo": false,
+ },
+ },
+};
+
+jest.mock("@/controllers/API/queries/models/use-get-enabled-models", () => ({
+ useGetEnabledModels: jest.fn(() => ({
+ data: mockEnabledModels,
+ isLoading: false,
+ })),
+}));
+
+const mockLLMModels: Model[] = [
+ { model_name: "gpt-4", metadata: { model_type: "llm", icon: "Bot" } },
+ { model_name: "gpt-3.5-turbo", metadata: { model_type: "llm", icon: "Bot" } },
+];
+
+const mockEmbeddingModels: Model[] = [
+ {
+ model_name: "text-embedding-ada-002",
+ metadata: { model_type: "embeddings", icon: "Bot" },
+ },
+ {
+ model_name: "embed-large",
+ metadata: { model_type: "embeddings", icon: "Bot" },
+ },
+];
+
+const allModels = [...mockLLMModels, ...mockEmbeddingModels];
+
+describe("ModelSelection", () => {
+ const defaultProps = {
+ availableModels: allModels,
+ onModelToggle: jest.fn(),
+ modelType: "all" as const,
+ providerName: "OpenAI",
+ isEnabledModel: true,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("Rendering", () => {
+ it("should render the component container", () => {
+ render(
);
+
+ expect(
+ screen.getByTestId("model-provider-selection"),
+ ).toBeInTheDocument();
+ });
+
+ it("should render LLM section when modelType is all", () => {
+ render(
);
+
+ expect(screen.getByTestId("llm-models-section")).toBeInTheDocument();
+ expect(screen.getByText("LLM Models")).toBeInTheDocument();
+ });
+
+ it("should render Embeddings section when modelType is all", () => {
+ render(
);
+
+ expect(
+ screen.getByTestId("embeddings-models-section"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Embedding Models")).toBeInTheDocument();
+ });
+
+ it("should only render LLM section when modelType is llm", () => {
+ render(
);
+
+ expect(screen.getByTestId("llm-models-section")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("embeddings-models-section"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("should only render Embeddings section when modelType is embeddings", () => {
+ render(
);
+
+ expect(
+ screen.queryByTestId("llm-models-section"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByTestId("embeddings-models-section"),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe("Model Display", () => {
+ it("should render model names", () => {
+ render(
);
+
+ expect(screen.getByText("gpt-4")).toBeInTheDocument();
+ expect(screen.getByText("gpt-3.5-turbo")).toBeInTheDocument();
+ expect(screen.getByText("text-embedding-ada-002")).toBeInTheDocument();
+ expect(screen.getByText("embed-large")).toBeInTheDocument();
+ });
+
+ it("should render toggle switches when isEnabledModel is true", () => {
+ render(
);
+
+ expect(screen.getByTestId("llm-toggle-gpt-4")).toBeInTheDocument();
+ expect(
+ screen.getByTestId("llm-toggle-gpt-3.5-turbo"),
+ ).toBeInTheDocument();
+ });
+
+ it("should not render toggle switches when isEnabledModel is false", () => {
+ render(
);
+
+ expect(screen.queryByTestId("llm-toggle-gpt-4")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Toggle Interaction", () => {
+ it("should call onModelToggle when toggle is clicked", async () => {
+ const onModelToggle = jest.fn();
+ const user = userEvent.setup();
+
+ render(
+
,
+ );
+
+ const toggle = screen.getByTestId("llm-toggle-gpt-4");
+ await user.click(toggle);
+
+ expect(onModelToggle).toHaveBeenCalledWith("gpt-4", expect.any(Boolean));
+ });
+ });
+
+ describe("Empty States", () => {
+ it("should not render LLM section when no LLM models", () => {
+ render(
+
,
+ );
+
+ expect(
+ screen.queryByTestId("llm-models-section"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("should not render Embeddings section when no embedding models", () => {
+ render(
+
,
+ );
+
+ expect(
+ screen.queryByTestId("embeddings-models-section"),
+ ).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/frontend/src/modals/modelProviderModal/__tests__/ProviderList.test.tsx b/src/frontend/src/modals/modelProviderModal/__tests__/ProviderList.test.tsx
new file mode 100644
index 000000000000..2bb2416e1c89
--- /dev/null
+++ b/src/frontend/src/modals/modelProviderModal/__tests__/ProviderList.test.tsx
@@ -0,0 +1,159 @@
+import { render, screen } from "@testing-library/react";
+import ProviderList from "../components/ProviderList";
+
+// Mock ForwardedIconComponent
+jest.mock("@/components/common/genericIconComponent", () => ({
+ __esModule: true,
+ ForwardedIconComponent: ({
+ name,
+ className,
+ }: {
+ name: string;
+ className?: string;
+ }) => (
+
+ {name}
+
+ ),
+}));
+
+// Mock LoadingTextComponent
+jest.mock("@/components/common/loadingTextComponent", () => ({
+ __esModule: true,
+ default: ({ text }: { text: string }) => (
+
{text}
+ ),
+}));
+
+// Mock provider data
+const mockProviders = [
+ {
+ provider: "OpenAI",
+ icon: "Bot",
+ is_enabled: true,
+ models: [
+ { model_name: "gpt-4", metadata: { model_type: "llm" } },
+ { model_name: "gpt-3.5-turbo", metadata: { model_type: "llm" } },
+ {
+ model_name: "text-embedding-ada-002",
+ metadata: { model_type: "embeddings" },
+ },
+ ],
+ },
+ {
+ provider: "Anthropic",
+ icon: "Brain",
+ is_enabled: false,
+ models: [{ model_name: "claude-3", metadata: { model_type: "llm" } }],
+ },
+];
+
+let mockIsLoading = false;
+let mockIsFetching = false;
+
+jest.mock("@/controllers/API/queries/models/use-get-model-providers", () => ({
+ useGetModelProviders: jest.fn(() => ({
+ data: mockProviders,
+ isLoading: mockIsLoading,
+ isFetching: mockIsFetching,
+ })),
+}));
+
+// Mock ProviderListItem
+jest.mock("../components/ProviderListItem", () => ({
+ __esModule: true,
+ default: ({ provider, isSelected, onSelect }: any) => (
+
onSelect(provider)}
+ >
+ {provider.provider} - {provider.model_count} models
+
+ ),
+}));
+
+describe("ProviderList", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockIsLoading = false;
+ mockIsFetching = false;
+ });
+
+ describe("Loading State", () => {
+ it("should show loading state when isLoading is true", () => {
+ mockIsLoading = true;
+
+ // Re-import to get fresh mock
+ const useGetModelProvidersMock =
+ require("@/controllers/API/queries/models/use-get-model-providers").useGetModelProviders;
+ useGetModelProvidersMock.mockReturnValueOnce({
+ data: [],
+ isLoading: true,
+ isFetching: false,
+ });
+
+ render(
);
+
+ expect(screen.getByTestId("provider-list-loading")).toBeInTheDocument();
+ expect(screen.getByText("Loading providers")).toBeInTheDocument();
+ });
+ });
+
+ describe("Provider Display", () => {
+ it("should render provider list container", () => {
+ render(
);
+
+ expect(screen.getByTestId("provider-list")).toBeInTheDocument();
+ });
+
+ it("should render providers with all model types", () => {
+ render(
);
+
+ expect(screen.getByTestId("provider-item-OpenAI")).toBeInTheDocument();
+ expect(screen.getByTestId("provider-item-Anthropic")).toBeInTheDocument();
+ });
+
+ it("should filter providers by LLM model type", () => {
+ render(
);
+
+ // Both providers have LLM models
+ expect(screen.getByTestId("provider-item-OpenAI")).toBeInTheDocument();
+ expect(screen.getByTestId("provider-item-Anthropic")).toBeInTheDocument();
+ });
+
+ it("should filter providers by embeddings model type", () => {
+ render(
);
+
+ // Only OpenAI has embedding models
+ expect(screen.getByTestId("provider-item-OpenAI")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("provider-item-Anthropic"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Selection", () => {
+ it("should call onProviderSelect when provider is clicked", () => {
+ const onProviderSelect = jest.fn();
+
+ render(
+
,
+ );
+
+ screen.getByTestId("provider-item-OpenAI").click();
+
+ expect(onProviderSelect).toHaveBeenCalled();
+ });
+
+ it("should pass selectedProviderName to items", () => {
+ render(
);
+
+ const openaiItem = screen.getByTestId("provider-item-OpenAI");
+ expect(openaiItem).toHaveAttribute("data-selected", "true");
+
+ const anthropicItem = screen.getByTestId("provider-item-Anthropic");
+ expect(anthropicItem).toHaveAttribute("data-selected", "false");
+ });
+ });
+});
diff --git a/src/frontend/src/modals/modelProviderModal/__tests__/ProviderListItem.test.tsx b/src/frontend/src/modals/modelProviderModal/__tests__/ProviderListItem.test.tsx
new file mode 100644
index 000000000000..32dbfe257113
--- /dev/null
+++ b/src/frontend/src/modals/modelProviderModal/__tests__/ProviderListItem.test.tsx
@@ -0,0 +1,160 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import ProviderListItem from "../components/ProviderListItem";
+import { Provider } from "../components/types";
+
+// Mock ForwardedIconComponent
+jest.mock("@/components/common/genericIconComponent", () => ({
+ __esModule: true,
+ ForwardedIconComponent: ({
+ name,
+ className,
+ }: {
+ name: string;
+ className?: string;
+ }) => (
+
+ {name}
+
+ ),
+}));
+
+const mockEnabledProvider: Provider = {
+ provider: "OpenAI",
+ icon: "Bot",
+ is_enabled: true,
+ model_count: 5,
+ models: [],
+};
+
+const mockDisabledProvider: Provider = {
+ provider: "Anthropic",
+ icon: "Brain",
+ is_enabled: false,
+ model_count: 3,
+ models: [],
+};
+
+const mockProviderNoModels: Provider = {
+ provider: "Empty",
+ icon: "Bot",
+ is_enabled: true,
+ model_count: 0,
+ models: [],
+};
+
+describe("ProviderListItem", () => {
+ const defaultProps = {
+ provider: mockEnabledProvider,
+ onSelect: jest.fn(),
+ isSelected: false,
+ showIcon: false,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("Rendering", () => {
+ it("should render the provider item", () => {
+ render(
);
+
+ expect(screen.getByTestId("provider-item-OpenAI")).toBeInTheDocument();
+ });
+
+ it("should display provider name", () => {
+ render(
);
+
+ expect(screen.getByText("OpenAI")).toBeInTheDocument();
+ });
+
+ it("should display model count badge for enabled provider", () => {
+ render(
);
+
+ expect(screen.getByText("5 models")).toBeInTheDocument();
+ });
+
+ it("should display singular 'model' for count of 1", () => {
+ const providerWithOneModel = {
+ ...mockEnabledProvider,
+ model_count: 1,
+ };
+
+ render(
+
,
+ );
+
+ expect(screen.getByText("1 model")).toBeInTheDocument();
+ });
+
+ it("should not display model count for disabled provider", () => {
+ render(
+
,
+ );
+
+ expect(screen.queryByText(/models?$/)).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Selection State", () => {
+ it("should apply selected styling when isSelected is true", () => {
+ render(
);
+
+ const item = screen.getByTestId("provider-item-OpenAI");
+ expect(item).toHaveClass("bg-muted/50");
+ });
+
+ it("should call onSelect when clicked", async () => {
+ const onSelect = jest.fn();
+ const user = userEvent.setup();
+
+ render(
);
+
+ const item = screen.getByTestId("provider-item-OpenAI");
+ await user.click(item);
+
+ expect(onSelect).toHaveBeenCalledWith(mockEnabledProvider);
+ });
+ });
+
+ describe("Enabled/Disabled State", () => {
+ it("should show check icon for enabled provider when showIcon is false", () => {
+ render(
);
+
+ expect(screen.getByTestId("icon-check")).toBeInTheDocument();
+ });
+
+ it("should show Plus icon for disabled provider when showIcon is false", () => {
+ render(
+
,
+ );
+
+ expect(screen.getByTestId("icon-Plus")).toBeInTheDocument();
+ });
+
+ it("should not show status icon when showIcon is true", () => {
+ render(
);
+
+ expect(screen.queryByTestId("icon-check")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("icon-Plus")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Cursor State", () => {
+ it("should have pointer cursor for provider with models", () => {
+ render(
);
+
+ const item = screen.getByTestId("provider-item-OpenAI");
+ expect(item).toHaveClass("cursor-pointer");
+ });
+
+ it("should have not-allowed cursor for provider without models", () => {
+ render(
+
,
+ );
+
+ const item = screen.getByTestId("provider-item-Empty");
+ expect(item).toHaveClass("cursor-not-allowed");
+ });
+ });
+});
diff --git a/src/frontend/src/modals/modelProviderModal/components/ModelProviderActive.tsx b/src/frontend/src/modals/modelProviderModal/components/ModelProviderActive.tsx
new file mode 100644
index 000000000000..1eebabb4e5c0
--- /dev/null
+++ b/src/frontend/src/modals/modelProviderModal/components/ModelProviderActive.tsx
@@ -0,0 +1,73 @@
+import ForwardedIconComponent from "@/components/common/genericIconComponent";
+import { Badge } from "@/components/ui/badge";
+
+export interface ModelProviderActiveProps {
+ /** List of active LLM model names */
+ activeLLMs: string[];
+ /** List of active embedding model names */
+ activeEmbeddings: string[];
+}
+
+/**
+ * Displays badges for currently active LLM and embedding models.
+ * Shown in the provider edit panel to indicate which models are enabled.
+ */
+const ModelProviderActive = ({
+ activeLLMs,
+ activeEmbeddings,
+}: ModelProviderActiveProps) => {
+ return (
+
+
+ Models{" "}
+ {" "}
+
+ {activeLLMs.length > 0 && (
+
+
LLM
+
+ {activeLLMs.map((model) => (
+
+ {model}
+
+ ))}
+
+
+ )}
+ {activeEmbeddings.length > 0 && (
+ <>
+
+ Embeddings
+
+
+ {activeEmbeddings.map((model) => (
+
+ {model}
+
+ ))}
+
+ >
+ )}
+
+ );
+};
+
+export default ModelProviderActive;
diff --git a/src/frontend/src/modals/modelProviderModal/components/ModelProviderEdit.tsx b/src/frontend/src/modals/modelProviderModal/components/ModelProviderEdit.tsx
new file mode 100644
index 000000000000..99b4653fbcee
--- /dev/null
+++ b/src/frontend/src/modals/modelProviderModal/components/ModelProviderEdit.tsx
@@ -0,0 +1,84 @@
+import ForwardedIconComponent from "@/components/common/genericIconComponent";
+import { Input } from "@/components/ui/input";
+import { PROVIDER_VARIABLE_MAPPING } from "@/constants/providerConstants";
+
+export interface ModelProviderEditProps {
+ authName: string;
+ onAuthNameChange: (value: string) => void;
+ apiKey: string;
+ onApiKeyChange: (value: string) => void;
+ apiBase: string;
+ onApiBaseChange: (value: string) => void;
+ providerName?: string;
+}
+
+/**
+ * Form for configuring provider credentials (API key, base URL).
+ * Used when setting up a new provider or updating existing credentials.
+ */
+const ModelProviderEdit = ({
+ authName,
+ onAuthNameChange,
+ apiKey,
+ onApiKeyChange,
+ apiBase,
+ onApiBaseChange,
+ providerName, // Reserved for future provider-specific behavior
+}: ModelProviderEditProps) => {
+ return (
+
+
+ Authorization Name
+
+
+
onAuthNameChange(e.target.value)}
+ data-testid="auth-name-input"
+ />
+
+ API Key *
+
+
+
onApiKeyChange(e.target.value)}
+ data-testid="api-key-input"
+ />
+
+ Find your API key{" "}
+
+
+
+ API Base
+ {" "}
+
+
onApiBaseChange(e.target.value)}
+ data-testid="api-base-input"
+ />
+
+ );
+};
+
+export default ModelProviderEdit;
diff --git a/src/frontend/src/modals/modelProviderModal/components/ModelSelection.tsx b/src/frontend/src/modals/modelProviderModal/components/ModelSelection.tsx
new file mode 100644
index 000000000000..5f4fd9cf0864
--- /dev/null
+++ b/src/frontend/src/modals/modelProviderModal/components/ModelSelection.tsx
@@ -0,0 +1,121 @@
+import ForwardedIconComponent from "@/components/common/genericIconComponent";
+import { Switch } from "@/components/ui/switch";
+import { useGetEnabledModels } from "@/controllers/API/queries/models/use-get-enabled-models";
+
+import { Model } from "@/modals/modelProviderModal/components/types";
+
+export interface ModelProviderSelectionProps {
+ availableModels: Model[];
+ onModelToggle: (modelName: string, enabled: boolean) => void;
+ modelType: "llm" | "embeddings" | "all";
+ providerName?: string;
+ isEnabledModel?: boolean;
+}
+
+interface ModelRowProps {
+ model: Model;
+ enabled: boolean;
+ onToggle: (modelName: string, enabled: boolean) => void;
+ testIdPrefix: string;
+ isEnabledModel?: boolean;
+}
+
+/** Single row displaying a model with its toggle switch */
+const ModelRow = ({
+ onToggle,
+ model,
+ enabled,
+ testIdPrefix,
+ isEnabledModel,
+}: ModelRowProps) => (
+
+
+
+ {model.model_name}
+
+ {isEnabledModel && (
+
onToggle(model.model_name, checked)}
+ data-testid={`${testIdPrefix}-toggle-${model.model_name}`}
+ />
+ )}
+
+);
+
+/**
+ * Displays lists of LLM and embedding models with toggle switches.
+ * Allows users to enable/disable individual models for a provider.
+ */
+const ModelSelection = ({
+ modelType = "llm",
+ availableModels,
+ onModelToggle,
+ providerName,
+ isEnabledModel,
+}: ModelProviderSelectionProps) => {
+ const { data: enabledModelsData } = useGetEnabledModels();
+
+ const isModelEnabled = (modelName: string): boolean => {
+ if (!providerName || !enabledModelsData?.enabled_models) return false;
+ return enabledModelsData.enabled_models[providerName]?.[modelName] ?? false;
+ };
+
+ const llmModels = availableModels.filter(
+ (model) => model.metadata?.model_type === "llm",
+ );
+ const embeddingModels = availableModels.filter(
+ (model) => model.metadata?.model_type === "embeddings",
+ );
+
+ const renderModelSection = (
+ title: string,
+ models: Model[],
+ testIdPrefix: string,
+ ) => {
+ if (models.length === 0) return null;
+ return (
+
+
+ {title}
+
+
+ {models.map((model) => (
+
+ ))}
+
+
+ );
+ };
+
+ return (
+
+ {modelType === "all" ? (
+ <>
+ {renderModelSection("LLM Models", llmModels, "llm")}
+ {renderModelSection(
+ "Embedding Models",
+ embeddingModels,
+ "embeddings",
+ )}
+ >
+ ) : modelType === "llm" ? (
+ renderModelSection("LLM Models", llmModels, "llm")
+ ) : (
+ renderModelSection("Embedding Models", embeddingModels, "embeddings")
+ )}
+
+ );
+};
+
+export default ModelSelection;
diff --git a/src/frontend/src/modals/modelProviderModal/components/ProviderList.tsx b/src/frontend/src/modals/modelProviderModal/components/ProviderList.tsx
new file mode 100644
index 000000000000..9e2396dc2e47
--- /dev/null
+++ b/src/frontend/src/modals/modelProviderModal/components/ProviderList.tsx
@@ -0,0 +1,81 @@
+import { useMemo } from "react";
+import LoadingTextComponent from "@/components/common/loadingTextComponent";
+import { useGetModelProviders } from "@/controllers/API/queries/models/use-get-model-providers";
+import ProviderListItem from "./ProviderListItem";
+import { Provider } from "./types";
+
+// Supported model types for filtering providers
+type ModelType = "llm" | "embeddings" | "all";
+
+export interface ProviderListProps {
+ modelType: ModelType;
+ onProviderSelect?: (provider: Provider) => void;
+ selectedProviderName?: string | null;
+}
+
+const ProviderList = ({
+ modelType,
+ onProviderSelect,
+ selectedProviderName,
+}: ProviderListProps) => {
+ const {
+ data: rawProviders = [],
+ isLoading,
+ isFetching,
+ } = useGetModelProviders({});
+
+ const filteredProviders: Provider[] = useMemo(() => {
+ return rawProviders
+ .map((provider) => {
+ const matchingModels =
+ provider?.models?.filter((model) =>
+ modelType === "all"
+ ? true
+ : model?.metadata?.model_type === modelType,
+ ) || [];
+
+ return {
+ provider: provider.provider,
+ icon: provider.icon,
+ is_enabled: provider.is_enabled,
+ model_count: matchingModels.length,
+ models: matchingModels,
+ };
+ })
+ .filter((provider) => provider.model_count > 0);
+ }, [rawProviders, modelType]);
+
+ const handleProviderSelect = (provider: Provider) => {
+ onProviderSelect?.(provider);
+ };
+
+ const isLoadingProviders =
+ isLoading || (isFetching && filteredProviders.length === 0);
+
+ if (isLoadingProviders) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {filteredProviders.map((provider) => (
+
+ ))}
+
+ );
+};
+
+export default ProviderList;
diff --git a/src/frontend/src/modals/modelProviderModal/components/ProviderListItem.tsx b/src/frontend/src/modals/modelProviderModal/components/ProviderListItem.tsx
new file mode 100644
index 000000000000..16a00cdd225c
--- /dev/null
+++ b/src/frontend/src/modals/modelProviderModal/components/ProviderListItem.tsx
@@ -0,0 +1,74 @@
+import { ForwardedIconComponent } from "@/components/common/genericIconComponent";
+import { Badge } from "@/components/ui/badge";
+import { cn } from "@/utils/utils";
+import { Provider } from "./types";
+
+export interface ProviderListItemProps {
+ provider: Provider;
+ isSelected?: boolean;
+ onSelect: (provider: Provider) => void;
+ showIcon?: boolean;
+}
+
+const ProviderListItem = ({
+ provider,
+ isSelected,
+ onSelect,
+ showIcon,
+}: ProviderListItemProps) => {
+ const hasModels = provider.model_count && provider.model_count > 0;
+ const isEnabled = provider.is_enabled;
+
+ return (
+
onSelect(provider)}
+ >
+
+
+
+
+ {provider.provider}
+
+ {provider.model_count !== undefined && isEnabled && (
+
+ {provider.model_count}{" "}
+ {provider.model_count === 1 ? "model" : "models"}
+
+ )}
+
+
+ {!showIcon && (
+
+ )}
+
+ );
+};
+
+export default ProviderListItem;
diff --git a/src/frontend/src/modals/modelProviderModal/components/types.ts b/src/frontend/src/modals/modelProviderModal/components/types.ts
new file mode 100644
index 000000000000..0e2962e9f42a
--- /dev/null
+++ b/src/frontend/src/modals/modelProviderModal/components/types.ts
@@ -0,0 +1,29 @@
+/** Represents a single AI model (LLM or embedding) */
+export type Model = {
+ model_name: string;
+ /** Arbitrary metadata including icon, model_type, deprecated, default flags */
+ metadata: Record
;
+};
+
+/** Represents a model provider (e.g., OpenAI, Anthropic) */
+export type Provider = {
+ provider: string;
+ icon?: string;
+ is_enabled: boolean;
+ model_count?: number;
+ models?: Model[];
+};
+
+/** Map of provider -> model_name -> enabled status */
+export type EnabledModelsData = {
+ enabled_models?: Record>;
+};
+
+/** Currently selected default model configuration */
+export type DefaultModelData = {
+ default_model?: {
+ model_name: string;
+ provider: string;
+ model_type: string;
+ } | null;
+};
diff --git a/src/frontend/src/modals/modelProviderModal/index.tsx b/src/frontend/src/modals/modelProviderModal/index.tsx
new file mode 100644
index 000000000000..35982d25b43e
--- /dev/null
+++ b/src/frontend/src/modals/modelProviderModal/index.tsx
@@ -0,0 +1,366 @@
+import { useQueryClient } from "@tanstack/react-query";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogHeader } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import {
+ NO_API_KEY_PROVIDERS,
+ PROVIDER_VARIABLE_MAPPING,
+ VARIABLE_CATEGORY,
+} from "@/constants/providerConstants";
+import { useUpdateEnabledModels } from "@/controllers/API/queries/models/use-update-enabled-models";
+import {
+ useGetGlobalVariables,
+ usePatchGlobalVariables,
+ usePostGlobalVariables,
+} from "@/controllers/API/queries/variables";
+import { useDebounce } from "@/hooks/use-debounce";
+import { useRefreshModelInputs } from "@/hooks/use-refresh-model-inputs";
+import ProviderList from "@/modals/modelProviderModal/components/ProviderList";
+import { Provider } from "@/modals/modelProviderModal/components/types";
+import useAlertStore from "@/stores/alertStore";
+import { cn } from "@/utils/utils";
+import ModelSelection from "./components/ModelSelection";
+
+interface ModelProviderModalProps {
+ open: boolean;
+ onClose: () => void;
+ modelType: "llm" | "embeddings" | "all";
+}
+
+const ModelProviderModal = ({
+ open,
+ onClose,
+ modelType,
+}: ModelProviderModalProps) => {
+ const [selectedProvider, setSelectedProvider] = useState(
+ null,
+ );
+ const [apiKey, setApiKey] = useState("");
+ const [validationFailed, setValidationFailed] = useState(false);
+ // Track if API key change came from user typing (vs programmatic reset)
+ // Used to prevent auto-save from triggering when we clear the input after success
+ const isUserInputRef = useRef(false);
+
+ const queryClient = useQueryClient();
+ const setSuccessData = useAlertStore((state) => state.setSuccessData);
+ const setErrorData = useAlertStore((state) => state.setErrorData);
+
+ const { mutate: createGlobalVariable, isPending: isCreating } =
+ usePostGlobalVariables();
+ const { mutate: updateGlobalVariable, isPending: isUpdating } =
+ usePatchGlobalVariables();
+ const { data: globalVariables = [] } = useGetGlobalVariables();
+ const { mutate: updateEnabledModels } = useUpdateEnabledModels();
+ const { refreshAllModelInputs } = useRefreshModelInputs();
+
+ const isPending = isCreating || isUpdating;
+
+ // Invalidate all provider-related caches after successful create/update
+ // This ensures the UI reflects the latest state across all components
+ const invalidateProviderQueries = () => {
+ queryClient.invalidateQueries({ queryKey: ["useGetModelProviders"] });
+ queryClient.invalidateQueries({ queryKey: ["useGetEnabledModels"] });
+ queryClient.invalidateQueries({ queryKey: ["useGetGlobalVariables"] });
+ queryClient.refetchQueries({ queryKey: ["flows"] });
+ };
+
+ // Reset form when provider changes
+ useEffect(() => {
+ setApiKey("");
+ setValidationFailed(false);
+ }, [selectedProvider?.provider]);
+
+ // Auto-save API key after user stops typing for 800ms
+ // The debounce prevents API calls on every keystroke
+ const debouncedConfigureProvider = useDebounce(() => {
+ if (apiKey.trim() && selectedProvider && isUserInputRef.current) {
+ handleConfigureProvider();
+ isUserInputRef.current = false;
+ }
+ }, 800);
+
+ // Trigger debounced save when apiKey changes from user input
+ useEffect(() => {
+ if (apiKey.trim() && isUserInputRef.current) {
+ debouncedConfigureProvider();
+ }
+ }, [apiKey, debouncedConfigureProvider]);
+
+ // Update enabled models when toggled
+ const handleModelToggle = (modelName: string, enabled: boolean) => {
+ if (!selectedProvider?.provider) return;
+
+ updateEnabledModels(
+ {
+ updates: [
+ {
+ provider: selectedProvider.provider,
+ model_id: modelName,
+ enabled,
+ },
+ ],
+ },
+ {
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["useGetEnabledModels"] });
+ },
+ },
+ );
+ };
+
+ // Toggle provider selection - clicking same provider deselects it
+ const handleProviderSelect = (provider: Provider) => {
+ setSelectedProvider((prev) =>
+ prev?.provider === provider.provider ? null : provider,
+ );
+ };
+
+ // Some providers (e.g., Ollama) don't require API keys - they just need activation
+ const requiresApiKey = useMemo(() => {
+ if (!selectedProvider) return true;
+ return !NO_API_KEY_PROVIDERS.includes(selectedProvider.provider);
+ }, [selectedProvider]);
+
+ // Activate providers that don't need API keys (e.g., Ollama)
+ // Creates/updates a global variable with a placeholder URL to mark provider as enabled
+ const handleActivateNoApiKeyProvider = () => {
+ if (!selectedProvider) return;
+
+ // Map provider name to its corresponding global variable name
+ const variableName = PROVIDER_VARIABLE_MAPPING[selectedProvider.provider];
+ if (!variableName) {
+ setErrorData({
+ title: "Invalid Provider",
+ list: [`Provider "${selectedProvider.provider}" is not supported.`],
+ });
+ return;
+ }
+
+ // Check if provider was previously configured (variable exists)
+ const existingVariable = globalVariables.find(
+ (v) => v.name === variableName,
+ );
+
+ // Ollama default endpoint - used as placeholder to mark provider as active
+ const placeholderValue = "http://localhost:11434";
+
+ const onSuccess = () => {
+ setSuccessData({ title: `${selectedProvider.provider} Activated` });
+ invalidateProviderQueries();
+ setSelectedProvider((prev) =>
+ prev ? { ...prev, is_enabled: true } : null,
+ );
+ };
+
+ const onError = (error: any) => {
+ setErrorData({
+ title: "Error Activating Provider",
+ list: [
+ error?.response?.data?.detail ||
+ "An unexpected error occurred. Please try again.",
+ ],
+ });
+ };
+
+ // Update existing variable or create new one
+ if (existingVariable) {
+ updateGlobalVariable(
+ { id: existingVariable.id, value: placeholderValue },
+ { onSuccess, onError },
+ );
+ } else {
+ createGlobalVariable(
+ {
+ name: variableName,
+ value: placeholderValue,
+ type: VARIABLE_CATEGORY.CREDENTIAL,
+ category: VARIABLE_CATEGORY.GLOBAL,
+ default_fields: [],
+ },
+ { onSuccess, onError },
+ );
+ }
+ };
+
+ // Save API key for providers that require authentication (e.g., OpenAI, Anthropic)
+ const handleConfigureProvider = () => {
+ if (!selectedProvider || !apiKey.trim()) return;
+
+ const variableName = PROVIDER_VARIABLE_MAPPING[selectedProvider.provider];
+ if (!variableName) {
+ setErrorData({
+ title: "Invalid Provider",
+ list: [`Provider "${selectedProvider.provider}" is not supported.`],
+ });
+ return;
+ }
+
+ // Check if provider was previously configured - determines update vs create
+ const existingVariable = globalVariables.find(
+ (v) => v.name === variableName,
+ );
+
+ const onSuccess = () => {
+ setSuccessData({ title: `${selectedProvider.provider} API Key Saved` });
+ invalidateProviderQueries();
+ setApiKey("");
+ setSelectedProvider((prev) =>
+ prev ? { ...prev, is_enabled: true } : null,
+ );
+ };
+
+ const onError = (error: any) => {
+ setValidationFailed(true);
+ setErrorData({
+ title: existingVariable
+ ? "Error Updating API Key"
+ : "Error Saving API Key",
+ list: [
+ error?.response?.data?.detail ||
+ "An unexpected error occurred. Please try again.",
+ ],
+ });
+ };
+
+ if (existingVariable) {
+ updateGlobalVariable(
+ { id: existingVariable.id, value: apiKey },
+ { onSuccess, onError },
+ );
+ } else {
+ createGlobalVariable(
+ {
+ name: variableName,
+ value: apiKey,
+ type: VARIABLE_CATEGORY.CREDENTIAL,
+ category: VARIABLE_CATEGORY.GLOBAL,
+ default_fields: [],
+ },
+ { onSuccess, onError },
+ );
+ }
+ };
+
+ const handleClose = () => {
+ refreshAllModelInputs({ silent: true });
+ onClose();
+ };
+
+ return (
+
+ );
+};
+
+export default ModelProviderModal;
diff --git a/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx b/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx
index 60afa87a7bfe..be43b81ca466 100644
--- a/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx
+++ b/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx
@@ -742,10 +742,16 @@ export default function Page({
};
return (
-
+
{showCanvas ? (
<>
-
+
{!view && (
<>
diff --git a/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/sidebarHeader/index.tsx b/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/sidebarHeader/index.tsx
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/src/frontend/src/pages/FlowPage/index.tsx b/src/frontend/src/pages/FlowPage/index.tsx
index f59457ffec35..3f7144ada579 100644
--- a/src/frontend/src/pages/FlowPage/index.tsx
+++ b/src/frontend/src/pages/FlowPage/index.tsx
@@ -1,12 +1,19 @@
import { useEffect, useState } from "react";
import { useBlocker, useParams } from "react-router-dom";
+import { FlowPageSlidingContainerContent } from "@/components/core/playgroundComponent/sliding-container/components/flow-page-sliding-container";
+import { useSlidingContainerStore } from "@/components/core/playgroundComponent/sliding-container/stores";
import { SidebarProvider } from "@/components/ui/sidebar";
+import {
+ SimpleSidebar,
+ SimpleSidebarProvider,
+} from "@/components/ui/simple-sidebar";
import { useGetFlow } from "@/controllers/API/queries/flows/use-get-flow";
import { useGetTypes } from "@/controllers/API/queries/flows/use-get-types";
import { ENABLE_NEW_SIDEBAR } from "@/customization/feature-flags";
import { useCustomNavigate } from "@/customization/hooks/use-custom-navigate";
import useSaveFlow from "@/hooks/flows/use-save-flow";
import { useIsMobile } from "@/hooks/use-mobile";
+import { useRefreshModelInputs } from "@/hooks/use-refresh-model-inputs";
import { SaveChangesModal } from "@/modals/saveChangesModal";
import useAlertStore from "@/stores/alertStore";
import { useTypesStore } from "@/stores/typesStore";
@@ -52,6 +59,7 @@ export default function FlowPage({ view }: { view?: boolean }): JSX.Element {
const stopBuilding = useFlowStore((state) => state.stopBuilding);
const { mutateAsync: getFlow } = useGetFlow();
+ const { refreshAllModelInputs } = useRefreshModelInputs();
const handleSave = () => {
let saving = true;
@@ -153,12 +161,32 @@ export default function FlowPage({ view }: { view?: boolean }): JSX.Element {
}, [blocker.state, isBuilding]);
const getFlowToAddToCanvas = async (id: string) => {
- const flow = await getFlow({ id: id });
+ const flow = await getFlow({ id });
setCurrentFlow(flow);
+ refreshAllModelInputs({ silent: true });
};
const isMobile = useIsMobile();
+ // Sliding container state - Will be reverted before merging this PR
+ const isSlidingContainerOpen = useSlidingContainerStore(
+ (state) => state.isOpen,
+ );
+ const slidingContainerWidth = useSlidingContainerStore(
+ (state) => state.width,
+ );
+ const setSlidingContainerWidth = useSlidingContainerStore(
+ (state) => state.setWidth,
+ );
+ const setSlidingContainerOpen = useSlidingContainerStore(
+ (state) => state.setIsOpen,
+ );
+ const isFullscreen = useSlidingContainerStore((state) => state.isFullscreen);
+ const setIsFullscreen = useSlidingContainerStore(
+ (state) => state.setIsFullscreen,
+ );
+ const hasIO = useFlowStore((state) => state.hasIO);
+
return (
<>
@@ -170,12 +198,44 @@ export default function FlowPage({ view }: { view?: boolean }): JSX.Element {
segmentedSidebar={ENABLE_NEW_SIDEBAR}
>
- {!view && }
-
-
-
+ {!view && !isFullscreen && (
+
+ )}
+ {
+ setSlidingContainerOpen(open);
+ if (!open) setIsFullscreen(false);
+ }}
+ fullscreen={isFullscreen}
+ onMaxWidth={() => {
+ setIsFullscreen(true);
+ setSlidingContainerOpen(true);
+ }}
+ >
+
+
+ {hasIO && (
+
+
+
+ )}
+
+
diff --git a/src/frontend/src/pages/MainPage/pages/homePage/components/McpAutoInstallContent.tsx b/src/frontend/src/pages/MainPage/pages/homePage/components/McpAutoInstallContent.tsx
index b6a86f706016..8fdf19f4337c 100644
--- a/src/frontend/src/pages/MainPage/pages/homePage/components/McpAutoInstallContent.tsx
+++ b/src/frontend/src/pages/MainPage/pages/homePage/components/McpAutoInstallContent.tsx
@@ -3,13 +3,18 @@ import ShadTooltip from "@/components/common/shadTooltipComponent";
import { Button } from "@/components/ui/button";
import { toSpaceCase } from "@/utils/stringManipulation";
import { cn } from "@/utils/utils";
+import type { MCPTransport } from "@/controllers/API/queries/mcp/use-patch-install-mcp";
import { autoInstallers } from "../utils/mcpServerUtils";
interface McpAutoInstallContentProps {
isLocalConnection: boolean;
installedMCPData?: Array<{ name?: string; available?: boolean }>;
loadingMCP: string[];
- installClient: (name: string, title?: string) => void;
+ installClient: (
+ name: string,
+ title?: string,
+ transport?: MCPTransport,
+ ) => void;
installedClients?: string[];
}
@@ -58,7 +63,13 @@ export const McpAutoInstallContent = ({
(client) => client.name === installer.name,
)?.available
}
- onClick={() => installClient(installer.name, installer.title)}
+ onClick={() =>
+ installClient(
+ installer.name,
+ installer.title,
+ installer.transport,
+ )
+ }
>
void;
+ selectedTransport: MCPTransport;
+ setSelectedTransport: (transport: MCPTransport) => void;
isDarkMode: boolean;
isCopied: boolean;
copyToClipboard: (text: string) => void;
@@ -109,6 +112,8 @@ MemoizedCodeTag.displayName = "MemoizedCodeTag";
export const McpJsonContent = ({
selectedPlatform,
setSelectedPlatform,
+ selectedTransport,
+ setSelectedTransport,
isDarkMode,
isCopied,
copyToClipboard,
@@ -119,20 +124,38 @@ export const McpJsonContent = ({
generateApiKey,
}: McpJsonContentProps) => (
<>
-
-
- {operatingSystemTabs.map((tab) => (
-
-
- {tab.title}
-
- ))}
-
-
+
+
+
+
+ {operatingSystemTabs.map((tab) => (
+
+
+ {tab.title}
+
+ ))}
+
+
+
+
+
+ Transport
+
+ setSelectedTransport(value as MCPTransport)}
+ >
+
+ SSE
+ Streamable HTTP
+
+
+
+
{
const [selectedMode, setSelectedMode] = useState(
isLocalConnection ? "Auto install" : "JSON",
);
+ const [selectedTransport, setSelectedTransport] =
+ useState("streamablehttp");
const {
flowsMCPData,
currentAuthSettings,
@@ -54,7 +57,12 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
hasAuthentication,
isAuthApiKey,
hasOAuthError,
- } = useMcpServer({ projectId, folderName, selectedPlatform });
+ } = useMcpServer({
+ projectId,
+ folderName,
+ selectedPlatform,
+ selectedTransport,
+ });
return (
@@ -139,6 +147,8 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
({
ForwardedIconComponent: ({ name }: { name: string }) => {name},
@@ -99,7 +100,34 @@ describe("McpAutoInstallContent", () => {
const buttons = screen.getAllByRole("button");
fireEvent.click(buttons[0]);
- expect(mockInstall).toHaveBeenCalledWith("cursor", "Cursor");
+ expect(mockInstall).toHaveBeenCalledWith(
+ "cursor",
+ "Cursor",
+ "streamablehttp",
+ );
+ });
+
+ it("calls installClient without transport for legacy installers", () => {
+ const mockInstall = jest.fn();
+ const originalTransport = autoInstallers[0].transport;
+ autoInstallers[0].transport = undefined;
+ try {
+ render(
+ ,
+ );
+
+ const buttons = screen.getAllByRole("button");
+ fireEvent.click(buttons[0]);
+ expect(mockInstall).toHaveBeenCalledWith("cursor", "Cursor", undefined);
+ } finally {
+ autoInstallers[0].transport = originalTransport;
+ }
});
it("disables button when not local connection", () => {
diff --git a/src/frontend/src/pages/MainPage/pages/homePage/components/__tests__/McpJsonContent.test.tsx b/src/frontend/src/pages/MainPage/pages/homePage/components/__tests__/McpJsonContent.test.tsx
index 229c959884c1..5918c9dfdf8e 100644
--- a/src/frontend/src/pages/MainPage/pages/homePage/components/__tests__/McpJsonContent.test.tsx
+++ b/src/frontend/src/pages/MainPage/pages/homePage/components/__tests__/McpJsonContent.test.tsx
@@ -43,35 +43,67 @@ jest.mock("@/components/ui/button", () => ({
),
}));
-jest.mock("@/components/ui/tabs-button", () => ({
- Tabs: ({
+jest.mock("@/components/ui/tabs-button", () => {
+ const React = require("react");
+
+ const cloneChildrenWith = (
+ children: React.ReactNode,
+ extraProps: Record,
+ ) =>
+ React.Children.map(children, (child) =>
+ React.isValidElement(child)
+ ? React.cloneElement(child, extraProps)
+ : child,
+ );
+
+ const Tabs = ({
children,
onValueChange,
}: {
children: React.ReactNode;
onValueChange: (v: string) => void;
}) => (
-
- {children}
+
+ {cloneChildrenWith(children, { __onValueChange: onValueChange })}
- ),
- TabsList: ({ children }: { children: React.ReactNode }) => (
-
{children}
- ),
- TabsTrigger: ({
+ );
+
+ const TabsList = ({
+ children,
+ __onValueChange,
+ }: {
+ children: React.ReactNode;
+ __onValueChange?: (v: string) => void;
+ }) =>
{cloneChildrenWith(children, { __onValueChange })}
;
+
+ const TabsTrigger = ({
children,
value,
onClick,
+ __onValueChange,
}: {
children: React.ReactNode;
value: string;
onClick?: () => void;
+ __onValueChange?: (v: string) => void;
}) => (
-