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

EnxDev pushed a commit to branch enxdev/chat-prototype
in repository https://gitbox.apache.org/repos/asf/superset.git

commit e2e679ae23e40dca0625933b205ad46353d7fb4d
Author: Enzo Martellucci <[email protected]>
AuthorDate: Tue May 26 16:09:28 2026 +0200

    feat(extensions): define the chatbot entry point in the frontend API
    
    Adds getActiveChatbot() singleton resolver (first-to-register + admin
    active_chatbot_id + enabled-flag enforcement), subscribeToLocation() for
    reactive re-resolution, and ChatbotMount — the fixed bottom-right slot
    that persists across routes and renders the active chatbot.
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
---
 .../components/ChatbotMount/ChatbotMount.test.tsx  | 91 ++++++++++++++++++++
 .../src/components/ChatbotMount/index.tsx          | 84 +++++++++++++++++++
 superset-frontend/src/core/chatbot/index.test.ts   | 96 ++++++++++++++++++++++
 superset-frontend/src/core/chatbot/index.ts        | 87 ++++++++++++++++++++
 superset-frontend/src/core/views/index.test.ts     | 63 +++++++++++++-
 superset-frontend/src/core/views/index.ts          | 46 +++++++++++
 6 files changed, 466 insertions(+), 1 deletion(-)

diff --git 
a/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx 
b/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx
new file mode 100644
index 00000000000..ba546005398
--- /dev/null
+++ b/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx
@@ -0,0 +1,91 @@
+/**
+ * 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.
+ */
+import React from 'react';
+import { render, screen } from 'spec/helpers/testing-library';
+import { views } from 'src/core';
+import { CHATBOT_LOCATION } from 'src/views/contributions';
+import ChatbotMount from '.';
+
+const disposables: Array<{ dispose: () => void }> = [];
+
+afterEach(() => {
+  disposables.forEach(d => d.dispose());
+  disposables.length = 0;
+});
+
+test('renders nothing when no chatbot extension is registered', () => {
+  render(<ChatbotMount />);
+
+  expect(screen.queryByTestId('chatbot-mount')).not.toBeInTheDocument();
+});
+
+test('renders the registered chatbot inside the fixed mount slot', () => {
+  const provider = () => React.createElement('div', null, 'My Chatbot Bubble');
+  disposables.push(
+    views.registerView(
+      { id: 'superset.chatbot', name: 'Superset Chatbot' },
+      CHATBOT_LOCATION,
+      provider,
+    ),
+  );
+
+  render(<ChatbotMount />);
+
+  expect(screen.getByTestId('chatbot-mount')).toBeInTheDocument();
+  expect(screen.getByText('My Chatbot Bubble')).toBeInTheDocument();
+});
+
+test('renders only the first-to-register chatbot when several are installed', 
() => {
+  const firstProvider = () => React.createElement('div', null, 'First Bubble');
+  const secondProvider = () =>
+    React.createElement('div', null, 'Second Bubble');
+  disposables.push(
+    views.registerView(
+      { id: 'first.chatbot', name: 'First Chatbot' },
+      CHATBOT_LOCATION,
+      firstProvider,
+    ),
+    views.registerView(
+      { id: 'second.chatbot', name: 'Second Chatbot' },
+      CHATBOT_LOCATION,
+      secondProvider,
+    ),
+  );
+
+  render(<ChatbotMount />);
+
+  expect(screen.getByText('First Bubble')).toBeInTheDocument();
+  expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
+});
+
+test('isolates a failing chatbot so it does not crash the host', () => {
+  const FailingChatbot = () => {
+    throw new Error('chatbot blew up');
+  };
+  disposables.push(
+    views.registerView(
+      { id: 'superset.chatbot', name: 'Superset Chatbot' },
+      CHATBOT_LOCATION,
+      () => React.createElement(FailingChatbot),
+    ),
+  );
+
+  // The host-owned error boundary catches the failure; render does not throw.
+  expect(() => render(<ChatbotMount />)).not.toThrow();
+});
diff --git a/superset-frontend/src/components/ChatbotMount/index.tsx 
b/superset-frontend/src/components/ChatbotMount/index.tsx
new file mode 100644
index 00000000000..68e692c57a7
--- /dev/null
+++ b/superset-frontend/src/components/ChatbotMount/index.tsx
@@ -0,0 +1,84 @@
+/**
+ * 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.
+ */
+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';
+import { subscribeToLocation } from 'src/core/views';
+import { CHATBOT_LOCATION } from 'src/views/contributions';
+
+const CHATBOT_EDGE_MARGIN = 24;
+
+const ChatbotMount = () => {
+  const theme = useTheme();
+  const [adminSelectedId, setAdminSelectedId] = useState<string | null>(null);
+  const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
+  const [activeChatbot, setActiveChatbot] = useState(() =>
+    getActiveChatbot(null, {}),
+  );
+
+  useEffect(() => {
+    let cancelled = false;
+    SupersetClient.get({ endpoint: '/api/v1/extensions/settings' })
+      .then(({ json }) => {
+        if (cancelled) return;
+        const id = json.result?.active_chatbot_id ?? null;
+        const enabled: Record<string, boolean> = json.result?.enabled ?? {};
+        setAdminSelectedId(id);
+        setEnabledMap(enabled);
+        setActiveChatbot(getActiveChatbot(id, enabled));
+      })
+      .catch(() => {
+        // Settings fetch failure is non-fatal — fall back to 
first-to-register.
+      });
+    return () => {
+      cancelled = true;
+    };
+  }, []);
+
+  useEffect(
+    () =>
+      subscribeToLocation(CHATBOT_LOCATION, () =>
+        setActiveChatbot(getActiveChatbot(adminSelectedId, enabledMap)),
+      ),
+    [adminSelectedId, enabledMap],
+  );
+
+  if (!activeChatbot) {
+    return null;
+  }
+
+  return (
+    <div
+      data-test="chatbot-mount"
+      css={css`
+        position: fixed;
+        right: ${CHATBOT_EDGE_MARGIN}px;
+        bottom: ${CHATBOT_EDGE_MARGIN}px;
+        /* Above dashboard content and the toast layer, below modal dialogs. */
+        z-index: ${theme.zIndexPopupBase + 2};
+      `}
+    >
+      <ErrorBoundary>{activeChatbot.provider()}</ErrorBoundary>
+    </div>
+  );
+};
+
+export default ChatbotMount;
diff --git a/superset-frontend/src/core/chatbot/index.test.ts 
b/superset-frontend/src/core/chatbot/index.test.ts
new file mode 100644
index 00000000000..0b8d6f2f6ec
--- /dev/null
+++ b/superset-frontend/src/core/chatbot/index.test.ts
@@ -0,0 +1,96 @@
+/**
+ * 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.
+ */
+import React from 'react';
+import { views } from 'src/core/views';
+import { CHATBOT_LOCATION } from 'src/views/contributions';
+import { getActiveChatbot } from './index';
+
+const disposables: Array<{ dispose: () => void }> = [];
+
+afterEach(() => {
+  disposables.forEach(d => d.dispose());
+  disposables.length = 0;
+});
+
+test('getActiveChatbot returns undefined when no chatbot is registered', () => 
{
+  expect(getActiveChatbot()).toBeUndefined();
+});
+
+test('getActiveChatbot resolves the single registered chatbot', () => {
+  const provider = () => React.createElement('div', null, 'Chatbot');
+  disposables.push(
+    views.registerView(
+      { id: 'superset.chatbot', name: 'Superset Chatbot' },
+      CHATBOT_LOCATION,
+      provider,
+    ),
+  );
+
+  const active = getActiveChatbot();
+  expect(active).toEqual({ id: 'superset.chatbot', provider });
+});
+
+test('getActiveChatbot picks the first-to-register when multiple are 
installed', () => {
+  const firstProvider = () => React.createElement('div', null, 'First');
+  const secondProvider = () => React.createElement('div', null, 'Second');
+  disposables.push(
+    views.registerView(
+      { id: 'first.chatbot', name: 'First Chatbot' },
+      CHATBOT_LOCATION,
+      firstProvider,
+    ),
+    views.registerView(
+      { id: 'second.chatbot', name: 'Second Chatbot' },
+      CHATBOT_LOCATION,
+      secondProvider,
+    ),
+  );
+
+  const active = getActiveChatbot();
+  expect(active?.id).toBe('first.chatbot');
+  expect(active?.provider).toBe(firstProvider);
+});
+
+test('getActiveChatbot ignores views registered at other locations', () => {
+  const provider = () => React.createElement('div', null, 'Panel');
+  disposables.push(
+    views.registerView(
+      { id: 'some.panel', name: 'Some Panel' },
+      'sqllab.panels',
+      provider,
+    ),
+  );
+
+  expect(getActiveChatbot()).toBeUndefined();
+});
+
+test('getActiveChatbot stops resolving a chatbot once it is disposed', () => {
+  const provider = () => React.createElement('div', null, 'Chatbot');
+  const disposable = views.registerView(
+    { id: 'superset.chatbot', name: 'Superset Chatbot' },
+    CHATBOT_LOCATION,
+    provider,
+  );
+
+  expect(getActiveChatbot()?.id).toBe('superset.chatbot');
+
+  disposable.dispose();
+
+  expect(getActiveChatbot()).toBeUndefined();
+});
diff --git a/superset-frontend/src/core/chatbot/index.ts 
b/superset-frontend/src/core/chatbot/index.ts
new file mode 100644
index 00000000000..4cb92b4bc9a
--- /dev/null
+++ b/superset-frontend/src/core/chatbot/index.ts
@@ -0,0 +1,87 @@
+/**
+ * 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.
+ */
+/**
+ * @fileoverview Host-internal resolver for the exclusive `superset.chatbot`
+ * contribution area.
+ *
+ * `superset.chatbot` is a singleton contribution area: multiple chatbot
+ * extensions may register a view there, but the host renders exactly one.
+ * This module owns the host-side selection policy.
+ *
+ * This is host-internal infrastructure — it is NOT part of the public
+ * `@apache-superset/core` API. Extensions register via the public
+ * `views.registerView()`; only the host resolves which one is active.
+ */
+
+import { ReactElement } from 'react';
+import { CHATBOT_LOCATION } from 'src/views/contributions';
+import { getRegisteredViewIds, getViewProvider } from 'src/core/views';
+
+/**
+ * The resolved active chatbot: a view id paired with its renderable provider.
+ */
+export interface ActiveChatbot {
+  /** The registered view id of the selected chatbot. */
+  id: string;
+  /** The provider that renders the chatbot's React element. */
+  provider: () => ReactElement;
+}
+
+/**
+ * Resolves which single chatbot extension is currently active.
+ *
+ * Selection policy:
+ *  - If no chatbot is registered, returns `undefined` — the corner stays 
empty.
+ *  - Disabled chatbots (per `enabledMap`) are excluded before selection.
+ *  - If `adminSelectedId` matches an enabled registered chatbot, that one 
wins.
+ *  - Otherwise the first enabled chatbot in registration order is used as a 
fallback.
+ *
+ * @param adminSelectedId The id stored in the admin "Default chatbot" 
setting, if any.
+ * @param enabledMap Per-extension enabled flags from the admin settings API.
+ * @returns The active chatbot's id and provider, or `undefined` if none.
+ */
+export const getActiveChatbot = (
+  adminSelectedId?: string | null,
+  enabledMap?: Record<string, boolean>,
+): ActiveChatbot | undefined => {
+  const registeredIds = getRegisteredViewIds(CHATBOT_LOCATION);
+  if (registeredIds.length === 0) {
+    return undefined;
+  }
+
+  const candidates = enabledMap
+    ? registeredIds.filter(id => enabledMap[id] !== false)
+    : registeredIds;
+
+  if (candidates.length === 0) {
+    return undefined;
+  }
+
+  const selectedId =
+    adminSelectedId && candidates.includes(adminSelectedId)
+      ? adminSelectedId
+      : candidates[0];
+
+  const provider = getViewProvider(CHATBOT_LOCATION, selectedId);
+  if (!provider) {
+    return undefined;
+  }
+
+  return { id: selectedId, provider };
+};
diff --git a/superset-frontend/src/core/views/index.test.ts 
b/superset-frontend/src/core/views/index.test.ts
index d98a05b7f4f..16413b109a0 100644
--- a/superset-frontend/src/core/views/index.test.ts
+++ b/superset-frontend/src/core/views/index.test.ts
@@ -17,7 +17,12 @@
  * under the License.
  */
 import React from 'react';
-import { views, resolveView } from './index';
+import {
+  views,
+  resolveView,
+  getViewProvider,
+  getRegisteredViewIds,
+} from './index';
 
 const disposables: Array<{ dispose: () => void }> = [];
 
@@ -110,3 +115,59 @@ test('dispose removes the view registration', () => {
 
   expect(views.getViews('sqllab.panels')).toBeUndefined();
 });
+
+test('getViewProvider returns the registered provider for a matching 
location', () => {
+  const provider = () => React.createElement('div', null, 'Test');
+  disposables.push(
+    views.registerView(
+      { id: 'test.provider', name: 'Test Provider' },
+      'superset.chatbot',
+      provider,
+    ),
+  );
+
+  expect(getViewProvider('superset.chatbot', 'test.provider')).toBe(provider);
+});
+
+test('getViewProvider returns undefined when the location does not match', () 
=> {
+  const provider = () => React.createElement('div', null, 'Test');
+  disposables.push(
+    views.registerView(
+      { id: 'test.provider', name: 'Test Provider' },
+      'sqllab.panels',
+      provider,
+    ),
+  );
+
+  // Registered, but at a different location.
+  expect(getViewProvider('superset.chatbot', 'test.provider')).toBeUndefined();
+});
+
+test('getViewProvider returns undefined for an unknown id', () => {
+  expect(getViewProvider('superset.chatbot', 'nonexistent')).toBeUndefined();
+});
+
+test('getRegisteredViewIds returns ids in registration order', () => {
+  const provider = () => React.createElement('div', null, 'Test');
+  disposables.push(
+    views.registerView(
+      { id: 'first.chatbot', name: 'First' },
+      'superset.chatbot',
+      provider,
+    ),
+    views.registerView(
+      { id: 'second.chatbot', name: 'Second' },
+      'superset.chatbot',
+      provider,
+    ),
+  );
+
+  expect(getRegisteredViewIds('superset.chatbot')).toEqual([
+    'first.chatbot',
+    'second.chatbot',
+  ]);
+});
+
+test('getRegisteredViewIds returns an empty array for an unused location', () 
=> {
+  expect(getRegisteredViewIds('superset.chatbot')).toEqual([]);
+});
diff --git a/superset-frontend/src/core/views/index.ts 
b/superset-frontend/src/core/views/index.ts
index 5bed7d10910..41c2a545ad5 100644
--- a/superset-frontend/src/core/views/index.ts
+++ b/superset-frontend/src/core/views/index.ts
@@ -39,6 +39,27 @@ const viewRegistry: Map<
 
 const locationIndex: Map<string, Set<string>> = new Map();
 
+/** Listeners notified whenever a view is registered or unregistered at a 
location. */
+const locationListeners: Map<string, Set<() => void>> = new Map();
+
+const notifyListeners = (location: string) => {
+  locationListeners.get(location)?.forEach(fn => fn());
+};
+
+/**
+ * Subscribe to registration changes at a specific location.
+ * Returns an unsubscribe function.
+ */
+export const subscribeToLocation = (
+  location: string,
+  listener: () => void,
+): (() => void) => {
+  const listeners = locationListeners.get(location) ?? new Set();
+  listeners.add(listener);
+  locationListeners.set(location, listeners);
+  return () => listeners.delete(listener);
+};
+
 const registerView: typeof viewsApi.registerView = (
   view: View,
   location: string,
@@ -52,9 +73,12 @@ const registerView: typeof viewsApi.registerView = (
   ids.add(id);
   locationIndex.set(location, ids);
 
+  notifyListeners(location);
+
   return new Disposable(() => {
     viewRegistry.delete(id);
     locationIndex.get(location)?.delete(id);
+    notifyListeners(location);
   });
 };
 
@@ -77,6 +101,28 @@ const getViews: typeof viewsApi.getViews = (
     .filter((c): c is View => !!c);
 };
 
+/**
+ * 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,
+  id: string,
+): (() => ReactElement) | undefined => {
+  const entry = viewRegistry.get(id);
+  if (entry?.location !== location) {
+    return undefined;
+  }
+  return entry.provider;
+};
+
+/** 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) : [];
+};
+
 export const views: typeof viewsApi = {
   registerView,
   getViews,

Reply via email to