(null);
+
+ // The active chat, the open state, and the display mode are read from one
+ // immutable registry snapshot so a render never mixes state from two
+ // different store versions (the tearing useSyncExternalStore prevents).
+ const {
+ open: panelOpen,
+ mode,
+ active,
+ } = useSyncExternalStore(subscribeToChatState, getChatSnapshot);
+
+ if (!active) {
+ return null;
+ }
+
+ const { registrationId } = active;
+
+ const onProviderError = (error: Error) => {
+ // Fault isolation: contain the crash, log it, surface a one-time
+ // notification, and leave the slot empty rather than parking a
+ // persistent error card.
+ logging.error('[chat] provider crashed', error);
+ if (crashNotifiedFor.current !== registrationId) {
+ crashNotifiedFor.current = registrationId;
+ store.dispatch(addDangerToast(t('The chat failed to load.')));
+ }
+ };
+
+ if (mode === 'panel') {
+ // Panel mode hides the trigger and docks the panel to the right edge.
+ // Interim approximation of the "layout slot between header and footer"
+ // from the chat API contract — the dock overlays the page until the host
+ // grows a real layout slot and resizer chrome.
+ if (!panelOpen) {
+ return null;
+ }
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/*
+ Each provider gets its own boundary so a crashing panel cannot take
+ the trigger down with it (the trigger is the user's only way back).
+ Keyed by registrationId: Superset's ErrorBoundary latches its error
+ state, so a takeover, fallback, or same-id re-registration must
+ remount the boundary to recover.
+ */}
+ {panelOpen && (
+
+
+
+ )}
+
+
+
+
+ );
+};
+
+export default ChatMount;
diff --git a/superset-frontend/src/core/chat/index.test.ts b/superset-frontend/src/core/chat/index.test.ts
new file mode 100644
index 000000000000..53765d6df94d
--- /dev/null
+++ b/superset-frontend/src/core/chat/index.test.ts
@@ -0,0 +1,327 @@
+/**
+ * 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 { createElement } from 'react';
+import { chat, getActiveChat, getChatSnapshot } from './index';
+
+const disposables: Array<{ dispose: () => void }> = [];
+
+const trigger = () => createElement('button', null, 'Bubble');
+const panel = () => createElement('div', null, 'Panel');
+
+afterEach(() => {
+ disposables.forEach(d => d.dispose());
+ disposables.length = 0;
+ // Reset host-owned state shared across tests in this module.
+ chat.close();
+ chat.setDisplayMode('floating');
+});
+
+test('getChat returns undefined when no chat is registered', () => {
+ expect(chat.getChat()).toBeUndefined();
+ expect(getActiveChat()).toBeUndefined();
+});
+
+test('registerChat resolves the registered chat with its providers', () => {
+ const descriptor = { id: 'acme.chat', name: 'Acme Chat' };
+ disposables.push(chat.registerChat(descriptor, trigger, panel));
+
+ expect(chat.getChat()).toEqual(descriptor);
+ expect(getActiveChat()).toMatchObject({ chat: descriptor, trigger, panel });
+});
+
+test('getChat returns a copy that cannot mutate the registry', () => {
+ disposables.push(
+ chat.registerChat({ id: 'acme.chat', name: 'Acme Chat' }, trigger, panel),
+ );
+
+ const copy = chat.getChat();
+ copy!.name = 'Hijacked';
+
+ expect(chat.getChat()?.name).toBe('Acme Chat');
+});
+
+test('the last-registered chat wins when multiple are installed', () => {
+ disposables.push(
+ chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
+ chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
+ );
+
+ expect(chat.getChat()?.id).toBe('second.chat');
+});
+
+test('disposing the active chat falls back to the previous registration', () => {
+ disposables.push(
+ chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
+ );
+ const second = chat.registerChat(
+ { id: 'second.chat', name: 'Second' },
+ trigger,
+ panel,
+ );
+
+ expect(chat.getChat()?.id).toBe('second.chat');
+
+ second.dispose();
+
+ expect(chat.getChat()?.id).toBe('first.chat');
+});
+
+test('re-registering an id replaces the previous registration', () => {
+ const stalePanel = () => createElement('div', null, 'Stale');
+ disposables.push(
+ chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, stalePanel),
+ chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
+ );
+
+ expect(chat.getChat()?.name).toBe('Acme v2');
+ expect(getActiveChat()?.panel).toBe(panel);
+});
+
+test('each registration gets a distinct registrationId, including same-id replacements', () => {
+ disposables.push(
+ chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
+ );
+ const first = getActiveChat()?.registrationId;
+
+ disposables.push(
+ chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
+ );
+ const second = getActiveChat()?.registrationId;
+
+ expect(first).toBeDefined();
+ expect(second).toBeDefined();
+ expect(second).not.toBe(first);
+});
+
+test('disposing a registration twice unregisters only once', () => {
+ const unregistered = jest.fn();
+ disposables.push(chat.onDidUnregisterChat(unregistered));
+
+ const registration = chat.registerChat(
+ { id: 'acme.chat', name: 'Acme' },
+ trigger,
+ panel,
+ );
+ registration.dispose();
+ registration.dispose();
+
+ expect(unregistered).toHaveBeenCalledTimes(1);
+ expect(chat.getChat()).toBeUndefined();
+});
+
+test('onDidRegisterChat and onDidUnregisterChat fire with the descriptor', () => {
+ const registered = jest.fn();
+ const unregistered = jest.fn();
+ disposables.push(
+ chat.onDidRegisterChat(registered),
+ chat.onDidUnregisterChat(unregistered),
+ );
+
+ const descriptor = { id: 'acme.chat', name: 'Acme' };
+ const registration = chat.registerChat(descriptor, trigger, panel);
+
+ expect(registered).toHaveBeenCalledWith(descriptor);
+ expect(unregistered).not.toHaveBeenCalled();
+
+ registration.dispose();
+
+ expect(unregistered).toHaveBeenCalledWith(descriptor);
+});
+
+test('a disposed event subscription stops receiving notifications', () => {
+ const registered = jest.fn();
+ const subscription = chat.onDidRegisterChat(registered);
+ subscription.dispose();
+
+ disposables.push(
+ chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
+ );
+
+ expect(registered).not.toHaveBeenCalled();
+});
+
+test('open and close toggle the panel and fire once', () => {
+ const opened = jest.fn();
+ const closed = jest.fn();
+ disposables.push(chat.onDidOpen(opened), chat.onDidClose(closed));
+
+ const descriptor = { id: 'acme.chat', name: 'Acme' };
+ disposables.push(chat.registerChat(descriptor, trigger, panel));
+
+ expect(chat.isOpen()).toBe(false);
+
+ chat.open();
+ // Opening an already-open panel is a no-op and must not re-fire.
+ chat.open();
+
+ expect(chat.isOpen()).toBe(true);
+ expect(opened).toHaveBeenCalledTimes(1);
+
+ chat.close();
+ chat.close();
+
+ expect(chat.isOpen()).toBe(false);
+ expect(closed).toHaveBeenCalledTimes(1);
+});
+
+test('open is a no-op while no chat is registered', () => {
+ const opened = jest.fn();
+ disposables.push(chat.onDidOpen(opened));
+
+ chat.open();
+
+ expect(chat.isOpen()).toBe(false);
+ expect(opened).not.toHaveBeenCalled();
+
+ // A registration arriving later therefore starts closed.
+ disposables.push(
+ chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
+ );
+ expect(chat.isOpen()).toBe(false);
+});
+
+test('a takeover by a different id closes the displaced chat panel', () => {
+ const closed = jest.fn();
+ disposables.push(chat.onDidClose(closed));
+
+ const first = { id: 'first.chat', name: 'First' };
+ disposables.push(chat.registerChat(first, trigger, panel));
+ chat.open();
+
+ disposables.push(
+ chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
+ );
+
+ // The incoming chat must not mount into an open state it never requested.
+ expect(chat.isOpen()).toBe(false);
+ expect(closed).toHaveBeenCalledTimes(1);
+});
+
+test('a same-id replacement keeps the open state', () => {
+ const closed = jest.fn();
+ disposables.push(chat.onDidClose(closed));
+
+ disposables.push(
+ chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
+ );
+ chat.open();
+
+ // Upgrade in place: same id, new providers.
+ disposables.push(
+ chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
+ );
+
+ expect(chat.isOpen()).toBe(true);
+ expect(closed).not.toHaveBeenCalled();
+});
+
+test('disposing the active chat while open closes it; the fallback starts closed', () => {
+ const closed = jest.fn();
+ disposables.push(chat.onDidClose(closed));
+
+ disposables.push(
+ chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
+ );
+ const second = { id: 'second.chat', name: 'Second' };
+ const registration = chat.registerChat(second, trigger, panel);
+ chat.open();
+
+ registration.dispose();
+
+ expect(chat.getChat()?.id).toBe('first.chat');
+ expect(chat.isOpen()).toBe(false);
+ expect(closed).toHaveBeenCalledTimes(1);
+});
+
+test('disposing an inactive registration leaves the open state untouched', () => {
+ const closed = jest.fn();
+ disposables.push(chat.onDidClose(closed));
+
+ const inactive = chat.registerChat(
+ { id: 'first.chat', name: 'First' },
+ trigger,
+ panel,
+ );
+ disposables.push(
+ chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
+ );
+ chat.open();
+
+ inactive.dispose();
+
+ expect(chat.isOpen()).toBe(true);
+ expect(closed).not.toHaveBeenCalled();
+});
+
+test('disposing the last chat while open resets the open state', () => {
+ const registration = chat.registerChat(
+ { id: 'acme.chat', name: 'Acme' },
+ trigger,
+ panel,
+ );
+ chat.open();
+ expect(chat.isOpen()).toBe(true);
+
+ registration.dispose();
+
+ expect(chat.isOpen()).toBe(false);
+
+ // A registration arriving much later must not inherit a stale open state.
+ disposables.push(
+ chat.registerChat({ id: 'late.chat', name: 'Late' }, trigger, panel),
+ );
+ expect(chat.isOpen()).toBe(false);
+});
+
+test('mode defaults to floating and setDisplayMode fires only on change', () => {
+ const modeChanged = jest.fn();
+ disposables.push(chat.onDidChangeDisplayMode(modeChanged));
+
+ expect(chat.getDisplayMode()).toBe('floating');
+
+ // Setting the current mode is a no-op.
+ chat.setDisplayMode('floating');
+ expect(modeChanged).not.toHaveBeenCalled();
+
+ chat.setDisplayMode('panel');
+ expect(chat.getDisplayMode()).toBe('panel');
+ expect(modeChanged).toHaveBeenCalledWith('panel');
+});
+
+test('the snapshot is immutable per version and consistent with the registry', () => {
+ const before = getChatSnapshot();
+
+ disposables.push(
+ chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
+ );
+ chat.open();
+
+ const after = getChatSnapshot();
+ // Unchanged references for old snapshots; a new object per change.
+ expect(after).not.toBe(before);
+ expect(before.active).toBeUndefined();
+ expect(after).toMatchObject({
+ open: true,
+ mode: 'floating',
+ active: getActiveChat(),
+ });
+ expect(after.version).toBeGreaterThan(before.version);
+ // Stable reference between changes.
+ expect(getChatSnapshot()).toBe(after);
+});
diff --git a/superset-frontend/src/core/chat/index.ts b/superset-frontend/src/core/chat/index.ts
new file mode 100644
index 000000000000..b98a511286b2
--- /dev/null
+++ b/superset-frontend/src/core/chat/index.ts
@@ -0,0 +1,240 @@
+/**
+ * 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 implementation of the `chat` contribution type.
+ *
+ * Chat is a dedicated contribution type, not a view: extensions register via
+ * the public `chat.registerChat()` and the host owns mounting, open/close
+ * state, and the display mode. Multiple chat extensions may register, but the
+ * host applies singleton resolution — the most-recently-registered chat is
+ * active; disposing it falls back to the previous one.
+ *
+ * Open-state policy across active-chat transitions: when the active chat's
+ * identity changes — a takeover by a different id, disposal falling back to a
+ * different id, or disposal of the last chat — the panel is closed (firing
+ * `onDidClose`) so the incoming chat never mounts into an open state it did
+ * not request. A same-id re-registration is an upgrade in place and keeps the
+ * open state.
+ *
+ * The public namespace (`chat`) is exposed to extensions on
+ * `window.superset`; the other exports are host-internal accessors for
+ * ChatMount and are NOT part of the public `@apache-superset/core` API.
+ */
+
+import { ReactElement } from 'react';
+import type { chat as chatApi } from '@apache-superset/core';
+import { Disposable } from '../models';
+import { createValueEventEmitter, createEventEmitter } from '../utils';
+
+type Chat = chatApi.Chat;
+type DisplayMode = chatApi.DisplayMode;
+
+/** A registered chat: its descriptor plus the host-mountable providers. */
+export interface RegisteredChat {
+ /** The chat descriptor passed to `registerChat`. */
+ chat: Chat;
+ /** Renders the collapsed bubble. Hidden by the host in panel mode. */
+ trigger: () => ReactElement;
+ /** Renders the chat panel, mounted per the current {@link DisplayMode}. */
+ panel: () => ReactElement;
+ /**
+ * Unique per registration (a same-id re-registration gets a new one). The
+ * host UI keys mounts and fault containment on it, so a replacement resets
+ * crashed error boundaries instead of inheriting their latched state.
+ */
+ registrationId: number;
+}
+
+/**
+ * Immutable snapshot of the whole chat state, rebuilt on every change.
+ * Returned by reference from `getChatSnapshot` so `useSyncExternalStore`
+ * consumers read registrations, open state, and mode from one consistent
+ * object instead of tearing across separate live reads.
+ */
+export interface ChatSnapshot {
+ /** Monotonic change counter, useful as a memo/effect dependency. */
+ version: number;
+ /** Whether the active chat's panel is open. */
+ open: boolean;
+ /** The current display mode. */
+ mode: DisplayMode;
+ /** The active registration, or undefined when none is registered. */
+ active: RegisteredChat | undefined;
+}
+
+/** Registration order is the singleton-resolution order: last entry wins. */
+const registrations: RegisteredChat[] = [];
+
+let panelOpen = false;
+let nextRegistrationId = 1;
+
+const registerEmitter = createEventEmitter();
+const unregisterEmitter = createEventEmitter();
+const openEmitter = createEventEmitter();
+const closeEmitter = createEventEmitter();
+const resizePanelEmitter = createEventEmitter<{ width: number }>();
+const modeEmitter = createValueEventEmitter('floating');
+
+/**
+ * Host-internal: resolves the active chat with its providers.
+ * The most-recently-registered chat wins; when it is disposed the previous
+ * registration takes over the slot again.
+ */
+export const getActiveChat = (): RegisteredChat | undefined =>
+ registrations[registrations.length - 1];
+
+let snapshot: ChatSnapshot = {
+ version: 0,
+ open: false,
+ mode: modeEmitter.getCurrent(),
+ active: undefined,
+};
+
+const stateSubscribers = new Set<() => void>();
+
+const notifyState = () => {
+ snapshot = {
+ version: snapshot.version + 1,
+ open: panelOpen,
+ mode: modeEmitter.getCurrent(),
+ active: getActiveChat(),
+ };
+ stateSubscribers.forEach(fn => fn());
+};
+
+export const subscribeToChatState = (listener: () => void): (() => void) => {
+ stateSubscribers.add(listener);
+ return () => {
+ stateSubscribers.delete(listener);
+ };
+};
+
+export const getChatSnapshot = (): ChatSnapshot => snapshot;
+
+/** Closes the panel and fires `onDidClose`. */
+const closePanel = () => {
+ panelOpen = false;
+ closeEmitter.fire();
+};
+
+const registerChat: typeof chatApi.registerChat = (
+ chat: Chat,
+ trigger: () => ReactElement,
+ panel: () => ReactElement,
+): Disposable => {
+ const previousActive = getActiveChat();
+
+ // Re-registering an id replaces the previous entry and moves it to the
+ // most-recent position, mirroring the view registry's same-id semantics.
+ const existingIndex = registrations.findIndex(r => r.chat.id === chat.id);
+ if (existingIndex !== -1) {
+ registrations.splice(existingIndex, 1);
+ }
+
+ const entry: RegisteredChat = {
+ chat,
+ trigger,
+ panel,
+ registrationId: nextRegistrationId,
+ };
+ nextRegistrationId += 1;
+ registrations.push(entry);
+ registerEmitter.fire(chat);
+
+ // A takeover by a different id closes the displaced chat's panel so the
+ // incoming chat never mounts already-open; a same-id replacement is an
+ // upgrade in place and keeps the open state.
+ if (panelOpen && previousActive && previousActive.chat.id !== chat.id) {
+ closePanel();
+ }
+ notifyState();
+
+ return new Disposable(() => {
+ const index = registrations.indexOf(entry);
+ if (index === -1) {
+ // Already removed — replaced by a same-id registration or disposed twice.
+ return;
+ }
+ const wasActive = getActiveChat() === entry;
+ registrations.splice(index, 1);
+ unregisterEmitter.fire(chat);
+ // Disposing the active chat closes its panel; the fallback chat (if any)
+ // starts closed. Disposing an inactive registration leaves the open
+ // state of the active chat untouched.
+ if (panelOpen && wasActive) {
+ closePanel();
+ }
+ notifyState();
+ });
+};
+
+const getChat: typeof chatApi.getChat = (): Chat | undefined => {
+ const active = getActiveChat();
+ // Copy so extensions cannot mutate another extension's descriptor.
+ return active ? { ...active.chat } : undefined;
+};
+
+const open: typeof chatApi.open = (): void => {
+ const active = getActiveChat();
+ // Open state only exists while a chat is registered; opening an empty slot
+ // would otherwise leak `open` into a future, unrelated registration.
+ if (panelOpen || !active) return;
+ panelOpen = true;
+ openEmitter.fire();
+ notifyState();
+};
+
+const close: typeof chatApi.close = (): void => {
+ const active = getActiveChat();
+ if (!panelOpen || !active) return;
+ closePanel();
+ notifyState();
+};
+
+const isOpen: typeof chatApi.isOpen = (): boolean => panelOpen;
+
+const getDisplayMode: typeof chatApi.getDisplayMode = (): DisplayMode =>
+ modeEmitter.getCurrent();
+
+const setDisplayMode: typeof chatApi.setDisplayMode = (
+ displayMode: DisplayMode,
+): void => {
+ if (displayMode === modeEmitter.getCurrent()) return;
+ modeEmitter.fire(displayMode);
+ notifyState();
+};
+
+export const chat: typeof chatApi = {
+ registerChat,
+ getChat,
+ onDidRegisterChat: registerEmitter.subscribe,
+ onDidUnregisterChat: unregisterEmitter.subscribe,
+ open,
+ close,
+ isOpen,
+ onDidOpen: openEmitter.subscribe,
+ onDidClose: closeEmitter.subscribe,
+ getDisplayMode,
+ setDisplayMode,
+ onDidChangeDisplayMode: modeEmitter.subscribe,
+ // The host fires this from its panel resizer; until that chrome exists the
+ // event is exposed but never fires.
+ onDidResizePanel: resizePanelEmitter.subscribe,
+};
diff --git a/superset-frontend/src/core/editors/EditorProviders.test.ts b/superset-frontend/src/core/editors/EditorProviders.test.ts
index c3d0580ef187..9f4ac0b2be2e 100644
--- a/superset-frontend/src/core/editors/EditorProviders.test.ts
+++ b/superset-frontend/src/core/editors/EditorProviders.test.ts
@@ -254,33 +254,6 @@ test('event listeners can be disposed', () => {
expect(listener).toHaveBeenCalledTimes(1); // Still only 1 call
});
-test('handles errors in event listeners gracefully', () => {
- const manager = EditorProviders.getInstance();
- const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
-
- const errorListener = jest.fn(() => {
- throw new Error('Listener error');
- });
- const successListener = jest.fn();
-
- manager.onDidRegister(errorListener);
- manager.onDidRegister(successListener);
-
- manager.registerProvider(createMockEditor(), createMockEditorComponent());
-
- // Both listeners should have been called
- expect(errorListener).toHaveBeenCalledTimes(1);
- expect(successListener).toHaveBeenCalledTimes(1);
-
- // Error should have been logged
- expect(consoleErrorSpy).toHaveBeenCalledWith(
- 'Error in event listener:',
- expect.any(Error),
- );
-
- consoleErrorSpy.mockRestore();
-});
-
test('reset clears all providers and language mappings', () => {
const manager = EditorProviders.getInstance();
diff --git a/superset-frontend/src/core/editors/EditorProviders.ts b/superset-frontend/src/core/editors/EditorProviders.ts
index 2eb57c44054b..2ae7cb3d4cb1 100644
--- a/superset-frontend/src/core/editors/EditorProviders.ts
+++ b/superset-frontend/src/core/editors/EditorProviders.ts
@@ -19,6 +19,7 @@
import type { editors } from '@apache-superset/core';
import { Disposable } from '../models';
+import { createEventEmitter } from '../utils';
type EditorLanguage = editors.EditorLanguage;
type EditorProvider = editors.EditorProvider;
@@ -27,45 +28,8 @@ type EditorComponent = editors.EditorComponent;
type EditorRegisteredEvent = editors.EditorRegisteredEvent;
type EditorUnregisteredEvent = editors.EditorUnregisteredEvent;
-/**
- * Listener function type for events.
- */
type Listener = (e: T) => void;
-/**
- * Simple event emitter for editor provider lifecycle events.
- */
-class EventEmitter {
- private listeners: Set> = new Set();
-
- /**
- * Subscribe to this event.
- * @param listener The listener function to call when the event is fired.
- * @returns A Disposable to unsubscribe from the event.
- */
- subscribe(listener: Listener): Disposable {
- this.listeners.add(listener);
- return new Disposable(() => {
- this.listeners.delete(listener);
- });
- }
-
- /**
- * Fire the event with the given data.
- * @param data The event data to pass to listeners.
- */
- fire(data: T): void {
- this.listeners.forEach(listener => {
- try {
- listener(data);
- } catch (error) {
- // eslint-disable-next-line no-console
- console.error('Error in event listener:', error);
- }
- });
- }
-}
-
/**
* Singleton manager for editor providers.
* Handles registration, resolution, and lifecycle of custom editor implementations.
@@ -83,15 +47,9 @@ class EditorProviders {
*/
private languageToProvider: Map = new Map();
- /**
- * Event emitter for provider registration events.
- */
- private registerEmitter = new EventEmitter();
+ private registerEmitter = createEventEmitter();
- /**
- * Event emitter for provider unregistration events.
- */
- private unregisterEmitter = new EventEmitter();
+ private unregisterEmitter = createEventEmitter();
private syncListeners: Set<() => void> = new Set();
diff --git a/superset-frontend/src/core/index.ts b/superset-frontend/src/core/index.ts
index 6a106ebe87ae..ce5b7f4d5052 100644
--- a/superset-frontend/src/core/index.ts
+++ b/superset-frontend/src/core/index.ts
@@ -27,11 +27,13 @@ export const core: typeof coreType = {
};
export * from './authentication';
+export * from './chat';
export * from './commands';
export * from './editors';
export * from './extensions';
export * from './menus';
export * from './models';
+export * from './navigation';
export * from './sqlLab';
export * from './utils';
export * from './views';
diff --git a/superset-frontend/src/core/menus/index.ts b/superset-frontend/src/core/menus/index.ts
index be2066a178b9..b11a0ff44b72 100644
--- a/superset-frontend/src/core/menus/index.ts
+++ b/superset-frontend/src/core/menus/index.ts
@@ -27,6 +27,7 @@
import { useSyncExternalStore } from 'react';
import type { menus as menusApi } from '@apache-superset/core';
import { Disposable } from '../models';
+import { createEventEmitter } from '../utils';
type MenuItem = menusApi.MenuItem;
type Menu = menusApi.Menu;
@@ -47,19 +48,19 @@ const subscribe = (listener: () => void) => {
return () => syncListeners.delete(listener);
};
-const registerListeners = new Set<(e: MenuItemRegisteredEvent) => void>();
-const unregisterListeners = new Set<(e: MenuItemUnregisteredEvent) => void>();
+const registerEmitter = createEventEmitter();
+const unregisterEmitter = createEventEmitter();
const menuCache = new Map();
const notifyRegister = (event: MenuItemRegisteredEvent) => {
menuCache.clear();
syncListeners.forEach(l => l());
- registerListeners.forEach(l => l(event));
+ registerEmitter.fire(event);
};
const notifyUnregister = (event: MenuItemUnregisteredEvent) => {
menuCache.clear();
syncListeners.forEach(l => l());
- unregisterListeners.forEach(l => l(event));
+ unregisterEmitter.fire(event);
};
const registerMenuItem: typeof menusApi.registerMenuItem = (
@@ -117,16 +118,11 @@ export const useMenu = (location: string): Menu | undefined =>
export const onDidRegisterMenuItem: typeof menusApi.onDidRegisterMenuItem = (
listener: (e: MenuItemRegisteredEvent) => void,
-): Disposable => {
- registerListeners.add(listener);
- return new Disposable(() => registerListeners.delete(listener));
-};
+): Disposable => registerEmitter.subscribe(listener);
export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem =
- (listener: (e: MenuItemUnregisteredEvent) => void): Disposable => {
- unregisterListeners.add(listener);
- return new Disposable(() => unregisterListeners.delete(listener));
- };
+ (listener: (e: MenuItemUnregisteredEvent) => void): Disposable =>
+ unregisterEmitter.subscribe(listener);
export const menus: typeof menusApi = {
registerMenuItem,
diff --git a/superset-frontend/src/core/navigation/index.test.ts b/superset-frontend/src/core/navigation/index.test.ts
new file mode 100644
index 000000000000..93fc2e03a96b
--- /dev/null
+++ b/superset-frontend/src/core/navigation/index.test.ts
@@ -0,0 +1,124 @@
+/**
+ * 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.
+ */
+
+// Reset module state between tests so currentPage is re-initialized.
+beforeEach(() => {
+ jest.resetModules();
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: { pathname: '/' },
+ });
+});
+
+async function importNavigation() {
+ const mod = await import('./index');
+ return mod;
+}
+
+test('getPage falls back to "home" for the welcome page and unknown pathnames', async () => {
+ const { navigation, notifyPageChange } = await importNavigation();
+ // The default pathname ('/') is not enumerated and falls back to home.
+ expect(navigation.getPage()).toBe('home');
+ notifyPageChange('/superset/welcome/');
+ expect(navigation.getPage()).toBe('home');
+});
+
+test('getPage derives the page from window.location.pathname', async () => {
+ window.location.pathname = '/superset/dashboard/42/';
+ const { navigation } = await importNavigation();
+ expect(navigation.getPage()).toBe('dashboard');
+});
+
+test('notifyPageChange updates the current page type', async () => {
+ const { navigation, notifyPageChange } = await importNavigation();
+ notifyPageChange('/explore/?form_data={}');
+ expect(navigation.getPage()).toBe('explore');
+});
+
+test('notifyPageChange fires listeners on page type change', async () => {
+ const { navigation, notifyPageChange } = await importNavigation();
+ const listener = jest.fn();
+ const disposable = navigation.onDidChangePage(listener);
+
+ notifyPageChange('/superset/dashboard/1/');
+ expect(listener).toHaveBeenCalledWith('dashboard');
+
+ disposable.dispose();
+});
+
+test('notifyPageChange does not fire listeners when page type is unchanged', async () => {
+ window.location.pathname = '/superset/dashboard/1/';
+ const { navigation, notifyPageChange } = await importNavigation();
+ const listener = jest.fn();
+ navigation.onDidChangePage(listener);
+
+ notifyPageChange('/superset/dashboard/2/');
+ expect(listener).not.toHaveBeenCalled();
+});
+
+test('onDidChangePage listener is removed after dispose', async () => {
+ const { navigation, notifyPageChange } = await importNavigation();
+ const listener = jest.fn();
+ const disposable = navigation.onDidChangePage(listener);
+
+ disposable.dispose();
+ notifyPageChange('/superset/dashboard/1/');
+ expect(listener).not.toHaveBeenCalled();
+});
+
+test('sqllab path is matched with and without trailing slash', async () => {
+ const { notifyPageChange, navigation } = await importNavigation();
+ notifyPageChange('/sqllab');
+ expect(navigation.getPage()).toBe('sqllab');
+ notifyPageChange('/explore/');
+ notifyPageChange('/sqllab/');
+ expect(navigation.getPage()).toBe('sqllab');
+});
+
+test('chart and dashboard list pages get their own page types', async () => {
+ const { notifyPageChange, navigation } = await importNavigation();
+ notifyPageChange('/chart/list/');
+ expect(navigation.getPage()).toBe('chart_list');
+ notifyPageChange('/dashboard/list/');
+ expect(navigation.getPage()).toBe('dashboard_list');
+});
+
+test('dataset list and single-dataset pages get distinct page types', async () => {
+ const { notifyPageChange, navigation } = await importNavigation();
+ notifyPageChange('/tablemodelview/list/');
+ expect(navigation.getPage()).toBe('dataset_list');
+ notifyPageChange('/dataset/42');
+ expect(navigation.getPage()).toBe('dataset');
+});
+
+test('sqllab editor, query history, and saved queries get distinct page types', async () => {
+ const { notifyPageChange, navigation } = await importNavigation();
+ notifyPageChange('/sqllab/');
+ expect(navigation.getPage()).toBe('sqllab');
+ notifyPageChange('/sqllab/history/');
+ expect(navigation.getPage()).toBe('query_history');
+ notifyPageChange('/savedqueryview/list/');
+ expect(navigation.getPage()).toBe('saved_queries');
+});
+
+test('chart/add resolves to explore, not chart_list', async () => {
+ const { notifyPageChange, navigation } = await importNavigation();
+ notifyPageChange('/chart/add');
+ expect(navigation.getPage()).toBe('explore');
+});
diff --git a/superset-frontend/src/core/navigation/index.ts b/superset-frontend/src/core/navigation/index.ts
new file mode 100644
index 000000000000..f670a1a45f04
--- /dev/null
+++ b/superset-frontend/src/core/navigation/index.ts
@@ -0,0 +1,79 @@
+/**
+ * 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.
+ */
+
+/**
+ * Host-internal implementation of the `navigation` namespace.
+ *
+ * Backed by browser location — no Redux dependency.
+ * The app shell calls `notifyPageChange(pathname)` whenever the route changes.
+ */
+
+import type { navigation as navigationApi } from '@apache-superset/core';
+import { Disposable } from '../models';
+import { createEventEmitter } from '../utils';
+
+type Page = navigationApi.Page;
+
+const pageChangeEmitter = createEventEmitter();
+
+function derivePage(pathname: string): Page {
+ if (pathname.startsWith('/superset/dashboard/')) return 'dashboard';
+ if (pathname.startsWith('/dashboard/list')) return 'dashboard_list';
+ if (pathname.startsWith('/explore/')) return 'explore';
+ if (pathname.startsWith('/superset/explore/')) return 'explore';
+ if (pathname.startsWith('/chart/add')) return 'explore';
+ if (pathname.startsWith('/chart/list')) return 'chart_list';
+ if (pathname.startsWith('/sqllab/history')) return 'query_history';
+ if (pathname.startsWith('/savedqueryview/list')) return 'saved_queries';
+ if (pathname === '/sqllab' || pathname.startsWith('/sqllab/'))
+ return 'sqllab';
+ if (pathname.startsWith('/tablemodelview/list')) return 'dataset_list';
+ if (pathname.startsWith('/dataset/')) return 'dataset';
+ // The welcome page and any route not explicitly enumerated fall back to home.
+ return 'home';
+}
+
+let currentPage: Page | undefined;
+
+function getOrInitPage(): Page {
+ if (currentPage === undefined) {
+ currentPage = derivePage(window.location.pathname);
+ }
+ return currentPage;
+}
+
+/** Called by ExtensionsStartup whenever the React Router location changes. */
+export const notifyPageChange = (pathname: string): void => {
+ const next = derivePage(pathname);
+ if (next === getOrInitPage()) return;
+ currentPage = next;
+ pageChangeEmitter.fire(next);
+};
+
+const getPage: typeof navigationApi.getPage = () => getOrInitPage();
+
+const onDidChangePage: typeof navigationApi.onDidChangePage = (
+ listener: (page: Page) => void,
+ thisArgs?: any,
+): Disposable => pageChangeEmitter.subscribe(listener, thisArgs);
+
+export const navigation: typeof navigationApi = {
+ getPage,
+ onDidChangePage,
+};
diff --git a/superset-frontend/src/core/utils.ts b/superset-frontend/src/core/utils.ts
index 1e4dded93c35..00c675523f1a 100644
--- a/superset-frontend/src/core/utils.ts
+++ b/superset-frontend/src/core/utils.ts
@@ -21,6 +21,57 @@ import { AnyAction } from 'redux';
import { listenerMiddleware, RootState, store } from 'src/views/store';
import { AnyListenerPredicate } from '@reduxjs/toolkit';
+type Listener = (e: T) => unknown;
+
+/** A stateless event emitter exposing a VS Code-style `event` subscriber. */
+export interface EventEmitter {
+ /** Notifies every current subscriber with `value`. */
+ fire(value: T): void;
+ /** Registers a listener; returns a Disposable that removes it. */
+ subscribe: core.Event;
+}
+
+/** An event emitter that also retains the last fired value. */
+export interface ValueEventEmitter extends EventEmitter {
+ /** Returns the value last passed to {@link fire} (or the initial value). */
+ getCurrent(): T;
+}
+
+/**
+ * Creates a stateless event emitter. Listeners registered via `event` receive
+ * every subsequent `fire`; a returned Disposable removes the listener.
+ */
+export function createEventEmitter(): EventEmitter {
+ const listeners = new Set>();
+ const subscribe: core.Event = (listener, thisArgs) => {
+ const bound = thisArgs ? listener.bind(thisArgs) : listener;
+ listeners.add(bound);
+ return { dispose: () => listeners.delete(bound) };
+ };
+ return {
+ fire: value => listeners.forEach(fn => fn(value)),
+ subscribe,
+ };
+}
+
+/**
+ * Creates a value event emitter seeded with `initial`. Behaves like
+ * {@link createEventEmitter} but also tracks the last fired value, readable
+ * via `getCurrent` — useful for state that is both observed and queried.
+ */
+export function createValueEventEmitter(initial: T): ValueEventEmitter {
+ const { fire, subscribe } = createEventEmitter();
+ let current = initial;
+ return {
+ fire: value => {
+ current = value;
+ fire(value);
+ },
+ subscribe,
+ getCurrent: () => current,
+ };
+}
+
export function createActionListener(
predicate: AnyListenerPredicate,
listener: (v: V) => void,
diff --git a/superset-frontend/src/core/views/index.ts b/superset-frontend/src/core/views/index.ts
index 3e8f775993e6..a28cfc4c37bf 100644
--- a/superset-frontend/src/core/views/index.ts
+++ b/superset-frontend/src/core/views/index.ts
@@ -29,6 +29,7 @@ import type { views as viewsApi } from '@apache-superset/core';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import ExtensionPlaceholder from 'src/extensions/ExtensionPlaceholder';
import { Disposable } from '../models';
+import { createEventEmitter } from '../utils';
type View = viewsApi.View;
type ViewRegisteredEvent = viewsApi.ViewRegisteredEvent;
@@ -47,19 +48,19 @@ const subscribe = (listener: () => void) => {
return () => syncListeners.delete(listener);
};
-const registerListeners = new Set<(e: ViewRegisteredEvent) => void>();
-const unregisterListeners = new Set<(e: ViewUnregisteredEvent) => void>();
+const registerEmitter = createEventEmitter();
+const unregisterEmitter = createEventEmitter();
const viewsCache = new Map();
const notifyRegister = (event: ViewRegisteredEvent) => {
viewsCache.clear();
syncListeners.forEach(l => l());
- registerListeners.forEach(l => l(event));
+ registerEmitter.fire(event);
};
const notifyUnregister = (event: ViewUnregisteredEvent) => {
viewsCache.clear();
syncListeners.forEach(l => l());
- unregisterListeners.forEach(l => l(event));
+ unregisterEmitter.fire(event);
};
const registerView: typeof viewsApi.registerView = (
@@ -116,17 +117,11 @@ export const useViews = (location: string): View[] | undefined =>
export const onDidRegisterView: typeof viewsApi.onDidRegisterView = (
listener: (e: ViewRegisteredEvent) => void,
-): Disposable => {
- registerListeners.add(listener);
- return new Disposable(() => registerListeners.delete(listener));
-};
+): Disposable => registerEmitter.subscribe(listener);
export const onDidUnregisterView: typeof viewsApi.onDidUnregisterView = (
listener: (e: ViewUnregisteredEvent) => void,
-): Disposable => {
- unregisterListeners.add(listener);
- return new Disposable(() => unregisterListeners.delete(listener));
-};
+): Disposable => unregisterEmitter.subscribe(listener);
export const views: typeof viewsApi = {
registerView,
diff --git a/superset-frontend/src/extensions/ExtensionsLoader.test.ts b/superset-frontend/src/extensions/ExtensionsLoader.test.ts
index 1d2493671ad1..d2debd6d69c1 100644
--- a/superset-frontend/src/extensions/ExtensionsLoader.test.ts
+++ b/superset-frontend/src/extensions/ExtensionsLoader.test.ts
@@ -31,7 +31,6 @@ function createMockExtension(overrides: Partial = {}): Extension {
version: '1.0.0',
dependencies: [],
remoteEntry: '',
- extensionDependencies: [],
...overrides,
};
}
diff --git a/superset-frontend/src/extensions/ExtensionsStartup.test.tsx b/superset-frontend/src/extensions/ExtensionsStartup.test.tsx
index 4a8d92c8d8a7..59ec6100bfe9 100644
--- a/superset-frontend/src/extensions/ExtensionsStartup.test.tsx
+++ b/superset-frontend/src/extensions/ExtensionsStartup.test.tsx
@@ -72,6 +72,7 @@ afterEach(() => {
test('renders without crashing', () => {
render(, {
useRedux: true,
+ useRouter: true,
initialState: mockInitialState,
});
@@ -88,6 +89,7 @@ test('sets up global superset object when user is logged in', async () => {
render(, {
useRedux: true,
+ useRouter: true,
initialState: mockInitialState,
});
@@ -95,6 +97,7 @@ test('sets up global superset object when user is logged in', async () => {
// Verify the global superset object is set up
expect((window as any).superset).toBeDefined();
expect((window as any).superset.authentication).toBeDefined();
+ expect((window as any).superset.chat).toBeDefined();
expect((window as any).superset.core).toBeDefined();
expect((window as any).superset.commands).toBeDefined();
expect((window as any).superset.extensions).toBeDefined();
@@ -109,6 +112,7 @@ test('sets up global superset object when user is logged in', async () => {
test('does not set up global superset object when user is not logged in', async () => {
render(, {
useRedux: true,
+ useRouter: true,
initialState: mockInitialStateNoUser,
});
@@ -127,6 +131,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
render(, {
useRedux: true,
+ useRouter: true,
initialState: mockInitialState,
});
@@ -144,6 +149,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
test('does not initialize ExtensionsLoader when user is not logged in', async () => {
render(, {
useRedux: true,
+ useRouter: true,
initialState: mockInitialStateNoUser,
});
@@ -169,6 +175,7 @@ test('only initializes once even with multiple renders', async () => {
const { rerender } = render(, {
useRedux: true,
+ useRouter: true,
initialState: mockInitialState,
});
@@ -205,6 +212,7 @@ test('initializes ExtensionsLoader when EnableExtensions feature flag is enabled
render(, {
useRedux: true,
+ useRouter: true,
initialState: mockInitialState,
});
@@ -234,6 +242,7 @@ test('does not initialize ExtensionsLoader when EnableExtensions feature flag is
render(, {
useRedux: true,
+ useRouter: true,
initialState: mockInitialState,
});
@@ -268,6 +277,7 @@ test('continues rendering children even when ExtensionsLoader initialization fai
,
{
useRedux: true,
+ useRouter: true,
initialState: mockInitialState,
},
);
diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx b/superset-frontend/src/extensions/ExtensionsStartup.tsx
index fba4e2318a19..1e972bdc6535 100644
--- a/superset-frontend/src/extensions/ExtensionsStartup.tsx
+++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx
@@ -16,58 +16,78 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useEffect } from 'react';
+import { useEffect, useRef } from 'react';
+import { useLocation } from 'react-router-dom';
+import { logging } from '@apache-superset/core/utils';
+import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
// eslint-disable-next-line no-restricted-syntax
import * as supersetCore from '@apache-superset/core';
-import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import {
authentication,
+ chat,
core,
commands,
editors,
extensions,
menus,
+ navigation,
sqlLab,
views,
} from 'src/core';
+import { notifyPageChange } from 'src/core/navigation';
import { useSelector } from 'react-redux';
import { RootState } from 'src/views/store';
import ExtensionsLoader from './ExtensionsLoader';
-
-declare global {
- interface Window {
- superset: {
- authentication: typeof authentication;
- core: typeof core;
- commands: typeof commands;
- editors: typeof editors;
- extensions: typeof extensions;
- menus: typeof menus;
- sqlLab: typeof sqlLab;
- views: typeof views;
- };
- }
-}
+import 'src/extensions/Namespaces';
const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
children,
}) => {
+ const location = useLocation();
+ const prevPathname = useRef(null);
+
const userId = useSelector(
({ user }) => user.userId,
);
+ // Notify the navigation namespace on every route change.
useEffect(() => {
- if (userId == null) return;
+ if (prevPathname.current !== location.pathname) {
+ prevPathname.current = location.pathname;
+ notifyPageChange(location.pathname);
+ }
+ }, [location.pathname]);
- // Provide the implementations for @apache-superset/core
+ // Log unhandled rejections that may originate from extension code.
+ // Registered once for the lifetime of the app; does not suppress the
+ // browser's default error surfacing so host error reporting is unaffected.
+ useEffect(() => {
+ const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
+ logging.error('[extensions] Unhandled rejection:', event.reason);
+ };
+ window.addEventListener('unhandledrejection', handleUnhandledRejection);
+ return () => {
+ window.removeEventListener(
+ 'unhandledrejection',
+ handleUnhandledRejection,
+ );
+ };
+ }, []);
+
+ useEffect(() => {
+ // Provide the implementations for @apache-superset/core.
+ // Namespaces are listed explicitly — do not spread the core package here,
+ // as that would leak un-contracted symbols onto window.superset.
window.superset = {
...supersetCore,
authentication,
+ chat,
core,
commands,
editors,
extensions,
menus,
+ navigation,
sqlLab,
views,
};
diff --git a/superset-frontend/src/extensions/Namespaces.ts b/superset-frontend/src/extensions/Namespaces.ts
new file mode 100644
index 000000000000..0e7f6a3063b2
--- /dev/null
+++ b/superset-frontend/src/extensions/Namespaces.ts
@@ -0,0 +1,60 @@
+/**
+ * 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.
+ */
+
+/**
+ * Global `window.superset` type augmentation.
+ *
+ * Lives in its own module (rather than inline in ExtensionsStartup) so every
+ * file that reads or writes `window.superset` — notably ExtensionsLoader —
+ * sees the type regardless of how files are batched during compilation. Both
+ * the startup component and the loader import this module for its side effect.
+ */
+
+import type {
+ authentication,
+ chat,
+ commands,
+ core,
+ editors,
+ extensions,
+ menus,
+ navigation,
+ sqlLab,
+ views,
+} from 'src/core';
+
+/** The host namespaces exposed to extensions on `window.superset`. */
+export interface Namespaces {
+ authentication: typeof authentication;
+ core: typeof core;
+ chat: typeof chat;
+ commands: typeof commands;
+ editors: typeof editors;
+ extensions: typeof extensions;
+ menus: typeof menus;
+ navigation: typeof navigation;
+ sqlLab: typeof sqlLab;
+ views: typeof views;
+}
+
+declare global {
+ interface Window {
+ superset: Namespaces;
+ }
+}
diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx
index 4f30a552e94e..6a5c08672ee5 100644
--- a/superset-frontend/src/views/App.tsx
+++ b/superset-frontend/src/views/App.tsx
@@ -38,7 +38,9 @@ import { Logger, LOG_ACTIONS_SPA_NAVIGATION } from 'src/logger/LogUtils';
import setupCodeOverrides from 'src/setup/setupCodeOverrides';
import { logEvent } from 'src/logger/actions';
import { store } from 'src/views/store';
+import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import ExtensionsStartup from 'src/extensions/ExtensionsStartup';
+import ChatMount from 'src/components/ChatMount';
import { RootContextProviders } from './RootContextProviders';
import { ScrollToTop } from './ScrollToTop';
@@ -112,6 +114,13 @@ const App = () => (
))}
+ {/*
+ The singleton chat slot. Rendered as a sibling of the route
+ Switch — inside ExtensionsStartup so chat extensions have been
+ loaded and registered, but outside the Switch so the chat persists
+ across route changes.
+ */}
+ {isFeatureEnabled(FeatureFlag.EnableExtensions) && }
diff --git a/superset/extensions/utils.py b/superset/extensions/utils.py
index 4455560696cd..fc5b6250bb6c 100644
--- a/superset/extensions/utils.py
+++ b/superset/extensions/utils.py
@@ -238,6 +238,7 @@ def build_extension_data(extension: LoadedExtension) -> dict[str, Any]:
manifest = extension.manifest
extension_data: dict[str, Any] = {
"id": manifest.id,
+ "publisher": manifest.publisher,
"name": extension.name,
"version": extension.version,
"description": manifest.description or "",