This is an automated email from the ASF dual-hosted git repository.

EnxDev pushed a commit to branch enxdev/feat/chatbot-p1-p2
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 6a07ad2369cf9d761ef64f6da5dce1caa653474f
Author: Enzo Martellucci <[email protected]>
AuthorDate: Tue May 26 10:18:41 2026 +0200

    feat(extensions): chatbot mount point, singleton resolver, and admin 
settings (SIP P1+P2)
---
 .../superset-core/src/contributions/index.ts       |  3 -
 .../src/components/ChatbotMount/index.tsx          | 44 +++++-----
 superset-frontend/src/core/chatbot/index.ts        | 25 +++---
 superset-frontend/src/core/views/index.ts          | 33 +-------
 .../src/extensions/ExtensionsList.tsx              | 98 ++++++++++++++++++++--
 superset/extensions/api.py                         | 49 ++++++++++-
 superset/extensions/settings.py                    | 55 ++++++++++++
 ...25_00-00_b2c3d4e5f6a7_add_extension_settings.py | 47 +++++++++++
 superset/models/core.py                            | 16 ++++
 9 files changed, 293 insertions(+), 77 deletions(-)

diff --git 
a/superset-frontend/packages/superset-core/src/contributions/index.ts 
b/superset-frontend/packages/superset-core/src/contributions/index.ts
index 42dddd90647..787f10261b5 100644
--- a/superset-frontend/packages/superset-core/src/contributions/index.ts
+++ b/superset-frontend/packages/superset-core/src/contributions/index.ts
@@ -18,11 +18,8 @@
  */
 
 import { View } from '../views';
-import type { ChatbotView } from '../views';
 import { Menu } from '../menus';
 
-export type { ChatbotView };
-
 export type SqlLabLocation =
   | 'leftSidebar'
   | 'rightSidebar'
diff --git a/superset-frontend/src/components/ChatbotMount/index.tsx 
b/superset-frontend/src/components/ChatbotMount/index.tsx
index 4efb9aa47e9..a88291c56fc 100644
--- a/superset-frontend/src/components/ChatbotMount/index.tsx
+++ b/superset-frontend/src/components/ChatbotMount/index.tsx
@@ -16,21 +16,8 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-/**
- * @fileoverview Host mount point for the singleton `superset.chatbot`
- * contribution area.
- *
- * The host owns the slot: a fixed bottom-right anchor that persists across all
- * routes, with a managed z-index. The extension owns everything rendered
- * inside it — the collapsed bubble, the expanded panel, all open/close state,
- * animations, and behavior (SIP §3.2 "Component contract").
- *
- * Singleton resolution (which of possibly several registered chatbots renders)
- * is delegated to `getActiveChatbot`. If no chatbot extension is registered,
- * this component renders nothing and the corner stays empty.
- */
-
 import { useState, useEffect } from 'react';
+import { SupersetClient } from '@superset-ui/core';
 import { css, useTheme } from '@apache-superset/core/theme';
 import { ErrorBoundary } from 'src/components/ErrorBoundary';
 import { getActiveChatbot } from 'src/core/chatbot';
@@ -39,24 +26,31 @@ import { CHATBOT_LOCATION } from 'src/views/contributions';
 
 const CHATBOT_EDGE_MARGIN = 24;
 
-/**
- * Renders the active chatbot extension into a fixed bottom-right slot.
- *
- * Mounted once at the app root so the bubble persists across routes.
- * Re-resolves when the chatbot registry changes (extension activated or
- * deactivated at runtime via the P1.A lifecycle contract).
- * Renders null when no chatbot extension is registered.
- */
 const ChatbotMount = () => {
   const theme = useTheme();
-  const [activeChatbot, setActiveChatbot] = useState(getActiveChatbot);
+  const [adminSelectedId, setAdminSelectedId] = useState<string | null>(null);
+  const [activeChatbot, setActiveChatbot] = useState(() =>
+    getActiveChatbot(null),
+  );
+
+  useEffect(() => {
+    SupersetClient.get({ endpoint: '/api/v1/extensions/settings' })
+      .then(({ json }) => {
+        const id = json.result?.active_chatbot_id ?? null;
+        setAdminSelectedId(id);
+        setActiveChatbot(getActiveChatbot(id));
+      })
+      .catch(() => {
+        // Settings fetch failure is non-fatal — fall back to 
first-to-register.
+      });
+  }, []);
 
   useEffect(
     () =>
       subscribeToLocation(CHATBOT_LOCATION, () =>
-        setActiveChatbot(getActiveChatbot()),
+        setActiveChatbot(getActiveChatbot(adminSelectedId)),
       ),
-    [],
+    [adminSelectedId],
   );
 
   if (!activeChatbot) {
diff --git a/superset-frontend/src/core/chatbot/index.ts 
b/superset-frontend/src/core/chatbot/index.ts
index 4598445ce6f..4b1d1bb00a7 100644
--- a/superset-frontend/src/core/chatbot/index.ts
+++ b/superset-frontend/src/core/chatbot/index.ts
@@ -46,28 +46,27 @@ export interface ActiveChatbot {
 /**
  * Resolves which single chatbot extension is currently active.
  *
- * Selection policy (P1):
+ * Selection policy:
  *  - If no chatbot is registered, returns `undefined` — the corner stays 
empty.
- *  - If one or more chatbots are registered, the first one to register wins.
- *
- * `Set` preserves insertion order, so "first to register" is deterministic.
- *
- * This is the P1 fallback policy. P2 introduces an admin "Default chatbot"
- * setting (SIP §4 option (c)); when that lands, the admin-selected id takes
- * precedence here and this first-to-register behavior remains only as the
- * fallback used when no admin setting is configured.
+ *  - If `adminSelectedId` is provided and matches a registered chatbot, that 
one wins.
+ *  - Otherwise the first-to-register chatbot is used as a fallback.
  *
+ * @param adminSelectedId The id stored in the admin "Default chatbot" 
setting, if any.
  * @returns The active chatbot's id and provider, or `undefined` if none.
  */
-export const getActiveChatbot = (): ActiveChatbot | undefined => {
+export const getActiveChatbot = (
+  adminSelectedId?: string | null,
+): ActiveChatbot | undefined => {
   const registeredIds = getRegisteredViewIds(CHATBOT_LOCATION);
   if (registeredIds.length === 0) {
     return undefined;
   }
 
-  // Deterministic first-to-register fallback. P2 will consult the admin
-  // "Default chatbot" setting before this point.
-  const [selectedId] = registeredIds;
+  const selectedId =
+    adminSelectedId && registeredIds.includes(adminSelectedId)
+      ? adminSelectedId
+      : registeredIds[0];
+
   const provider = getViewProvider(CHATBOT_LOCATION, selectedId);
   if (!provider) {
     return undefined;
diff --git a/superset-frontend/src/core/views/index.ts 
b/superset-frontend/src/core/views/index.ts
index 7925ef3d2d4..41c2a545ad5 100644
--- a/superset-frontend/src/core/views/index.ts
+++ b/superset-frontend/src/core/views/index.ts
@@ -102,23 +102,9 @@ const getViews: typeof viewsApi.getViews = (
 };
 
 /**
- * Host-internal accessor that returns the registered `provider` for a view id
- * at a given location.
- *
- * This is deliberately NOT part of the public `@apache-superset/core` `views`
- * API. The public `getViews` returns descriptors only (`id`/`name`/...), so an
- * extension can discover what is registered but cannot obtain — and therefore
- * cannot render — another extension's view outside the host's mount point,
- * lifecycle, and fault-isolation boundary.
- *
- * The host uses this accessor to render exclusive (singleton) contribution
- * areas such as `superset.chatbot`, where it must enumerate the candidates and
- * then render exactly one. See `getActiveChatbot` in `src/core/chatbot`.
- *
- * @param location The contribution location (e.g. `superset.chatbot`).
- * @param id The registered view id.
- * @returns The provider function, or undefined if no matching view is
- *   registered at that location.
+ * Host-internal: returns the provider for a registered view id at a location.
+ * Not part of the public `@apache-superset/core` API — `getViews` stays
+ * descriptor-only so extensions cannot render each other's views directly.
  */
 export const getViewProvider = (
   location: string,
@@ -131,18 +117,7 @@ export const getViewProvider = (
   return entry.provider;
 };
 
-/**
- * Host-internal accessor that returns the ordered list of view ids registered
- * at a location, in registration order.
- *
- * Registration order is meaningful for exclusive locations: the host's
- * deterministic fallback policy ("first to register wins") relies on it.
- * Like {@link getViewProvider}, this is host-internal and not part of the
- * public API.
- *
- * @param location The contribution location.
- * @returns View ids in registration order, or an empty array if none.
- */
+/** Host-internal: view ids at a location in registration order. */
 export const getRegisteredViewIds = (location: string): string[] => {
   const ids = locationIndex.get(location);
   return ids ? Array.from(ids) : [];
diff --git a/superset-frontend/src/extensions/ExtensionsList.tsx 
b/superset-frontend/src/extensions/ExtensionsList.tsx
index 6f4f9c2f56d..a2b3a953382 100644
--- a/superset-frontend/src/extensions/ExtensionsList.tsx
+++ b/superset-frontend/src/extensions/ExtensionsList.tsx
@@ -17,20 +17,31 @@
  * under the License.
  */
 import { t } from '@apache-superset/core/translation';
-import { FunctionComponent, useMemo } from 'react';
+import { css } from '@apache-superset/core/theme';
+import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 
'react';
+import { SupersetClient } from '@superset-ui/core';
+import { Select } from '@superset-ui/core/components';
+import { Switch } from '@superset-ui/core/components/Switch';
 import { useListViewResource } from 'src/views/CRUD/hooks';
 import { ListView } from 'src/components';
 import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
 import withToasts from 'src/components/MessageToasts/withToasts';
+import { CHATBOT_LOCATION } from 'src/views/contributions';
+import { getRegisteredViewIds } from 'src/core/views';
 
 const PAGE_SIZE = 25;
 
 type Extension = {
-  id: number;
+  id: string;
   name: string;
   enabled: boolean;
 };
 
+type ExtensionSettings = {
+  active_chatbot_id: string | null;
+  enabled: Record<string, boolean>;
+};
+
 interface ExtensionsListProps {
   addDangerToast: (msg: string) => void;
   addSuccessToast: (msg: string) => void;
@@ -50,6 +61,45 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> 
= ({
     addDangerToast,
   );
 
+  const [settings, setSettings] = useState<ExtensionSettings>({
+    active_chatbot_id: null,
+    enabled: {},
+  });
+
+  useEffect(() => {
+    SupersetClient.get({ endpoint: '/api/v1/extensions/settings' })
+      .then(({ json }) => setSettings(json.result))
+      .catch(() => addDangerToast(t('Failed to load extension settings.')));
+  }, [addDangerToast]);
+
+  const saveSettings = useCallback(
+    (patch: Partial<ExtensionSettings>) => {
+      const next = { ...settings, ...patch };
+      SupersetClient.put({
+        endpoint: '/api/v1/extensions/settings',
+        jsonPayload: next,
+      })
+        .then(({ json }) => {
+          setSettings(json.result);
+          addSuccessToast(t('Settings saved.'));
+        })
+        .catch(() => addDangerToast(t('Failed to save extension settings.')));
+    },
+    [settings, addDangerToast, addSuccessToast],
+  );
+
+  const toggleEnabled = useCallback(
+    (extensionId: string, enabled: boolean) => {
+      saveSettings({ enabled: { ...settings.enabled, [extensionId]: enabled } 
});
+    },
+    [settings, saveSettings],
+  );
+
+  const chatbotExtensions = useMemo(() => {
+    const chatbotIds = new Set(getRegisteredViewIds(CHATBOT_LOCATION));
+    return resourceCollection.filter(ext => chatbotIds.has(ext.id));
+  }, [resourceCollection]);
+
   const columns = useMemo(
     () => [
       {
@@ -58,15 +108,34 @@ const ExtensionsList: 
FunctionComponent<ExtensionsListProps> = ({
         size: 'lg',
         id: 'name',
         Cell: ({
-          row: {
-            original: { name },
-          },
+          row: { original: { name } },
         }: any) => name,
       },
+      {
+        Header: t('Enabled'),
+        accessor: 'enabled',
+        size: 'sm',
+        id: 'enabled',
+        Cell: ({
+          row: { original: { id, enabled } },
+        }: any) => (
+          <Switch
+            data-test="toggle-enabled"
+            checked={settings.enabled[id] ?? enabled}
+            onClick={(checked: boolean) => toggleEnabled(id, checked)}
+            size="small"
+          />
+        ),
+      },
     ],
-    [loading], // We need to monitor loading to avoid stale state in actions
+    [loading, settings, toggleEnabled],
   );
 
+  const chatbotOptions = chatbotExtensions.map(ext => ({
+    label: ext.name,
+    value: ext.id,
+  }));
+
   const menuData: SubMenuProps = {
     activeChild: 'Extensions',
     name: t('Extensions'),
@@ -76,6 +145,23 @@ const ExtensionsList: 
FunctionComponent<ExtensionsListProps> = ({
   return (
     <>
       <SubMenu {...menuData} />
+      {chatbotOptions.length > 1 && (
+        <div style={{ padding: '16px 24px' }}>
+          <label htmlFor="chatbot-select" style={{ marginRight: 8 }}>
+            {t('Default chatbot')}
+          </label>
+          <Select
+            allowClear
+            options={chatbotOptions}
+            value={settings.active_chatbot_id ?? undefined}
+            onChange={value =>
+              saveSettings({ active_chatbot_id: (value as string) ?? null })
+            }
+            placeholder={t('First registered (automatic)')}
+            css={css`width: 280px;`}
+          />
+        </div>
+      )}
       <ListView<Extension>
         columns={columns}
         count={resourceCount}
diff --git a/superset/extensions/api.py b/superset/extensions/api.py
index b1b5734979e..f39db3686ed 100644
--- a/superset/extensions/api.py
+++ b/superset/extensions/api.py
@@ -18,10 +18,14 @@ import mimetypes
 from io import BytesIO
 from typing import Any
 
-from flask import send_file
+from flask import request, send_file
 from flask.wrappers import Response
 from flask_appbuilder.api import BaseApi, expose, protect, safe
 
+from superset.extensions.settings import (
+    get_extension_settings,
+    update_extension_settings,
+)
 from superset.extensions.utils import (
     build_extension_data,
     get_extensions,
@@ -167,6 +171,49 @@ class ExtensionsRestApi(BaseApi):
         extension_data = build_extension_data(extension)
         return self.response(200, result=extension_data)
 
+    @protect()
+    @safe
+    @expose("/settings", methods=("GET",))
+    def get_settings(self, **kwargs: Any) -> Response:
+        """Get global extension admin settings.
+        ---
+        get:
+          summary: Get extension admin settings (active chatbot, enabled 
flags).
+          responses:
+            200:
+              description: Extension settings
+        """
+        return self.response(200, result=get_extension_settings())
+
+    @protect()
+    @safe
+    @expose("/settings", methods=("PUT",))
+    def put_settings(self, **kwargs: Any) -> Response:
+        """Update global extension admin settings.
+        ---
+        put:
+          summary: Update extension admin settings.
+          requestBody:
+            content:
+              application/json:
+                schema:
+                  type: object
+                  properties:
+                    active_chatbot_id:
+                      type: string
+                      nullable: true
+                    enabled:
+                      type: object
+                      additionalProperties:
+                        type: boolean
+          responses:
+            200:
+              description: Updated settings
+        """
+        body = request.get_json(silent=True) or {}
+        result = update_extension_settings(body)
+        return self.response(200, result=result)
+
     @protect()
     @safe
     @expose("/<publisher>/<name>/<file>", methods=("GET",))
diff --git a/superset/extensions/settings.py b/superset/extensions/settings.py
new file mode 100644
index 00000000000..30189df8e28
--- /dev/null
+++ b/superset/extensions/settings.py
@@ -0,0 +1,55 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Admin settings persistence for extensions (active chatbot, 
enable/disable)."""
+
+from typing import Any
+
+from superset import db
+from superset.models.core import ExtensionEnabled, ExtensionSettings
+
+_SETTINGS_ROW_ID = 1
+
+
+def get_extension_settings() -> dict[str, Any]:
+    row = db.session.get(ExtensionSettings, _SETTINGS_ROW_ID)
+    enabled_rows = db.session.query(ExtensionEnabled).all()
+    return {
+        "active_chatbot_id": row.active_chatbot_id if row else None,
+        "enabled": {r.extension_id: r.enabled for r in enabled_rows},
+    }
+
+
+def update_extension_settings(body: dict[str, Any]) -> dict[str, Any]:
+    row = db.session.get(ExtensionSettings, _SETTINGS_ROW_ID)
+    if row is None:
+        row = ExtensionSettings(id=_SETTINGS_ROW_ID)
+        db.session.add(row)
+
+    if "active_chatbot_id" in body:
+        row.active_chatbot_id = body["active_chatbot_id"] or None
+
+    if "enabled" in body:
+        for extension_id, enabled in body["enabled"].items():
+            flag = db.session.get(ExtensionEnabled, extension_id)
+            if flag is None:
+                flag = ExtensionEnabled(extension_id=extension_id)
+                db.session.add(flag)
+            flag.enabled = bool(enabled)
+
+    db.session.commit()
+    return get_extension_settings()
diff --git 
a/superset/migrations/versions/2026-05-25_00-00_b2c3d4e5f6a7_add_extension_settings.py
 
b/superset/migrations/versions/2026-05-25_00-00_b2c3d4e5f6a7_add_extension_settings.py
new file mode 100644
index 00000000000..9773a5104aa
--- /dev/null
+++ 
b/superset/migrations/versions/2026-05-25_00-00_b2c3d4e5f6a7_add_extension_settings.py
@@ -0,0 +1,47 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Add extension_settings table for chatbot admin selection and enable/disable.
+
+Revision ID: b2c3d4e5f6a7
+Revises: a1b2c3d4e5f6
+Create Date: 2026-05-25 00:00:00.000000
+
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+revision = "b2c3d4e5f6a7"
+down_revision = "a1b2c3d4e5f6"
+
+
+def upgrade() -> None:
+    op.create_table(
+        "extension_settings",
+        sa.Column("id", sa.Integer(), primary_key=True),
+        sa.Column("active_chatbot_id", sa.String(250), nullable=True),
+    )
+    op.create_table(
+        "extension_enabled",
+        sa.Column("extension_id", sa.String(250), primary_key=True),
+        sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("extension_enabled")
+    op.drop_table("extension_settings")
diff --git a/superset/models/core.py b/superset/models/core.py
index 1f99630ab3d..35a36f588d5 100755
--- a/superset/models/core.py
+++ b/superset/models/core.py
@@ -108,6 +108,22 @@ class KeyValue(Model):  # pylint: 
disable=too-few-public-methods
     value = Column(utils.MediumText(), nullable=False)
 
 
+class ExtensionSettings(Model):  # pylint: disable=too-few-public-methods
+    """Global admin settings for extensions (singleton row, id=1)."""
+
+    __tablename__ = "extension_settings"
+    id = Column(Integer, primary_key=True)
+    active_chatbot_id = Column(String(250), nullable=True)
+
+
+class ExtensionEnabled(Model):  # pylint: disable=too-few-public-methods
+    """Per-extension enable/disable flag."""
+
+    __tablename__ = "extension_enabled"
+    extension_id = Column(String(250), primary_key=True)
+    enabled = Column(Boolean, nullable=False, default=True)
+
+
 class CssTemplate(AuditMixinNullable, UUIDMixin, Model):
     """CSS templates for dashboards"""
 

Reply via email to