You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by en...@apache.org on 2026/05/26 14:13:06 UTC
(superset) 07/07: feat(extensions): reference chatbot extension — docs and example
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 2b884cb3fa9415701cf1ec1269d63db075486648
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Tue May 26 16:12:17 2026 +0200
feat(extensions): reference chatbot extension — docs and example
Ships the full extensions/chat reference implementation that exercises
the chatbot extension platform end-to-end: activation lifecycle and
master disposable (teardown contract), React error boundary (fault
isolation), mock streaming with AbortController cancellation, commands
registration, and the pageContext helper that composes host namespaces.
Local branch only — not intended for upstream merge.
Co-Authored-By: Claude Sonnet 4.6 <no...@anthropic.com>
---
extensions/chat/README.md | 138 ++++++++++++++
extensions/chat/extension.json | 40 ++++
extensions/chat/jest.config.js | 53 ++++++
extensions/chat/package.json | 26 +++
extensions/chat/src/ReferenceChatbot.tsx | 45 +++++
extensions/chat/src/__tests__/activate.test.tsx | 144 ++++++++++++++
extensions/chat/src/__tests__/sdkMock.ts | 119 ++++++++++++
extensions/chat/src/activate.ts | 88 +++++++++
extensions/chat/src/commands.ts | 47 +++++
extensions/chat/src/components/Bubble.tsx | 46 +++++
extensions/chat/src/components/ErrorBoundary.tsx | 66 +++++++
extensions/chat/src/components/Panel.tsx | 228 +++++++++++++++++++++++
extensions/chat/src/context/pageContext.ts | 114 ++++++++++++
extensions/chat/src/index.tsx | 30 +++
extensions/chat/src/state.ts | 57 ++++++
extensions/chat/src/streaming/mockStream.ts | 73 ++++++++
extensions/chat/src/streaming/registry.ts | 46 +++++
extensions/chat/tsconfig.json | 15 ++
extensions/chat/tsconfig.test.json | 15 ++
extensions/chat/webpack.config.js | 70 +++++++
20 files changed, 1460 insertions(+)
diff --git a/extensions/chat/README.md b/extensions/chat/README.md
new file mode 100644
index 00000000000..dece34ba327
--- /dev/null
+++ b/extensions/chat/README.md
@@ -0,0 +1,138 @@
+<!--
+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.
+-->
+
+# Reference Chatbot Extension
+
+Canonical environment-validation extension for the `superset.chatbot`
+contribution area. **Not** a product chatbot — there is no LLM, no backend,
+no persistence. Its purpose is to exercise the extension platform end-to-end:
+
+- `views.registerView` at `superset.chatbot` (singleton resolution)
+- Lifecycle activation + a master disposable that tears down everything
+- `commands.registerCommand` for `core.chatbot__open|close|toggle`
+- Mock streaming with `AbortController` cancellation on dispose
+- Defense-in-depth React error boundary inside the panel
+- A single P3 page-context seam that lights up automatically as the
+ `dashboard` / `explore` / `dataset` / `navigation` namespaces become
+ available at runtime on the host
+
+It is intended as the reference implementation third-party chatbot extension
+authors copy. Anything that ships as host-internal (the mount point, the
+admin picker, the `getActiveChatbot` resolver) is **not** here — see the
+host side at `superset-frontend/src/components/ChatbotMount/` and
+`superset-frontend/src/core/chatbot/`.
+
+## Layout
+
+```
+extensions/chat/
+├── extension.json Manifest (app.chatbot view + commands)
+├── package.json
+├── tsconfig.json
+├── webpack.config.js ModuleFederation → window.superset
+├── jest.config.js Self-contained unit tests
+└── src/
+ ├── index.tsx MF entry — calls activate() once
+ ├── activate.ts Returns master disposable
+ ├── commands.ts core.chatbot__open|close|toggle
+ ├── state.ts Module-scoped open/closed + emitter
+ ├── ReferenceChatbot.tsx Root component (bubble ↔ panel)
+ ├── components/
+ │ ├── Bubble.tsx
+ │ ├── Panel.tsx
+ │ └── ErrorBoundary.tsx
+ ├── streaming/
+ │ ├── mockStream.ts AsyncIterable<string> + AbortSignal
+ │ └── registry.ts Cross-component abort tracking
+ ├── context/
+ │ └── pageContext.ts P3 namespace seam (defensive)
+ └── __tests__/
+ ├── sdkMock.ts In-memory @apache-superset/core mock
+ └── activate.test.tsx
+```
+
+## Run the unit tests
+
+```bash
+cd extensions/chat
+npm install # first time only
+npx jest
+```
+
+The tests mock `@apache-superset/core` via `src/__tests__/sdkMock.ts` so they
+do not depend on host runtime wiring.
+
+## Build / bundle for deployment
+
+```bash
+# from the extension folder
+npm install
+npm run build
+
+# packaging into a .supx is handled by the Superset extensions CLI
+pip install apache-superset-extensions-cli
+superset-extensions bundle # produces apache-superset.reference-chatbot-0.1.0.supx
+```
+
+Drop the `.supx` into the `EXTENSIONS_PATH` of a Superset instance that has
+`FEATURE_FLAGS = { "ENABLE_EXTENSIONS": True }`.
+
+## Selecting it as the active chatbot
+
+The host's singleton picker reads `active_chatbot_id` from the admin
+settings endpoint (`/api/v1/extensions/settings`). Set it to:
+
+```
+apache-superset.reference-chatbot
+```
+
+If no admin selection exists, the host falls back to the first-to-register
+chatbot — installing this extension alone is enough for the bubble to appear.
+
+## P3 integration seams
+
+All page-context derivation lives in [`src/context/pageContext.ts`](src/context/pageContext.ts).
+Each namespace branch (`dashboard`, `explore`, `dataset`, `navigation`) is
+called defensively — when the host implementation lands, the returned value
+becomes non-undefined automatically with no other change in the extension.
+
+The panel re-reads context on `popstate`. Once `navigation.onDidChangePage`
+is live on the host, the panel's `useEffect` should subscribe to it instead;
+that is the only file in the extension that needs to change for full P3
+context sync.
+
+## Known intentional non-features
+
+- No conversation persistence — by design (extension scope per SIP §2).
+- No real network. The mock stream is a `setTimeout` token emitter so the
+ cancellation contract is exercised without external dependencies.
+- No keyboard shortcut binding (Cmd+K). Extensions own that, but it adds
+ surface area not needed for platform validation.
+- No notification badge / icon mutation. SIP §3.2 recommends static icons;
+ the bubble re-renders freely already.
+
+## TODOs
+
+- **P1**: if/when the host gains `deactivate(): Promise<void>`, wrap the
+ master disposer in `activate.ts` to flush async work before returning.
+- **P3**: replace the `popstate` listener in `Panel.tsx` with
+ `navigation.onDidChangePage` once that event is wired up host-side.
+- **P4**: if the host pre-registers `core.chatbot__*` as host-owned intents,
+ swap `commands.registerCommand` for the implementation hook in
+ `commands.ts`. Command IDs do not change.
diff --git a/extensions/chat/extension.json b/extensions/chat/extension.json
new file mode 100644
index 00000000000..58fc98c7e3c
--- /dev/null
+++ b/extensions/chat/extension.json
@@ -0,0 +1,40 @@
+{
+ "publisher": "apache-superset",
+ "name": "reference-chatbot",
+ "displayName": "Reference Chatbot",
+ "description": "Canonical environment-validation chatbot extension for the superset.chatbot contribution area. Exercises registration, lifecycle, singleton resolution, commands, fault isolation, and streaming teardown. Not a product chatbot.",
+ "version": "0.1.0",
+ "license": "Apache-2.0",
+ "permissions": [],
+ "contributes": {
+ "views": {
+ "app": {
+ "chatbot": [
+ {
+ "id": "apache-superset.reference-chatbot",
+ "name": "Reference Chatbot",
+ "description": "Validates the chatbot extension environment end-to-end.",
+ "icon": "Bubble"
+ }
+ ]
+ }
+ },
+ "commands": [
+ {
+ "id": "core.chatbot__open",
+ "title": "Open chatbot",
+ "description": "Opens the reference chatbot panel."
+ },
+ {
+ "id": "core.chatbot__close",
+ "title": "Close chatbot",
+ "description": "Closes the reference chatbot panel."
+ },
+ {
+ "id": "core.chatbot__toggle",
+ "title": "Toggle chatbot",
+ "description": "Toggles the reference chatbot panel."
+ }
+ ]
+ }
+}
diff --git a/extensions/chat/jest.config.js b/extensions/chat/jest.config.js
new file mode 100644
index 00000000000..7151d801622
--- /dev/null
+++ b/extensions/chat/jest.config.js
@@ -0,0 +1,53 @@
+/**
+ * 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.
+ */
+const path = require('path');
+
+// When run as a standalone package (`cd extensions/chat && npm test`), modules
+// resolve from this folder's own node_modules. When run from the superset-frontend
+// workspace (CI, dev convenience), resolve ts-jest there too.
+const tsJest = (() => {
+ try {
+ require.resolve('ts-jest');
+ return 'ts-jest';
+ } catch {
+ return path.resolve(
+ __dirname,
+ '..',
+ '..',
+ 'superset-frontend',
+ 'node_modules',
+ 'ts-jest',
+ );
+ }
+})();
+
+module.exports = {
+ testEnvironment: 'jsdom',
+ rootDir: __dirname,
+ testMatch: ['<rootDir>/src/**/*.test.{ts,tsx}'],
+ // When running from the extension folder without node_modules installed,
+ // resolve react / react-dom from the superset-frontend workspace.
+ modulePaths: [path.resolve(__dirname, '..', '..', 'superset-frontend', 'node_modules')],
+ moduleNameMapper: {
+ '^@apache-superset/core$': '<rootDir>/src/__tests__/sdkMock.ts',
+ },
+ transform: {
+ '^.+\\.tsx?$': [tsJest, { tsconfig: '<rootDir>/tsconfig.test.json' }],
+ },
+};
diff --git a/extensions/chat/package.json b/extensions/chat/package.json
new file mode 100644
index 00000000000..ace4b103324
--- /dev/null
+++ b/extensions/chat/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@apache-superset/reference-chatbot",
+ "version": "0.1.0",
+ "private": true,
+ "license": "Apache-2.0",
+ "description": "Reference chatbot extension that validates the Superset chatbot extension platform.",
+ "scripts": {
+ "start": "webpack serve --mode development",
+ "build": "webpack --stats-error-details --mode production"
+ },
+ "peerDependencies": {
+ "@apache-superset/core": "^0.1.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@apache-superset/core": "^0.1.0",
+ "@types/react": "^18.2.0",
+ "@types/react-dom": "^18.2.0",
+ "ts-loader": "^9.5.0",
+ "typescript": "^5.0.0",
+ "webpack": "^5.0.0",
+ "webpack-cli": "^5.0.0",
+ "webpack-dev-server": "^5.0.0"
+ }
+}
diff --git a/extensions/chat/src/ReferenceChatbot.tsx b/extensions/chat/src/ReferenceChatbot.tsx
new file mode 100644
index 00000000000..971950bc8c4
--- /dev/null
+++ b/extensions/chat/src/ReferenceChatbot.tsx
@@ -0,0 +1,45 @@
+/**
+ * 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, { useEffect, useState } from 'react';
+import { commands } from '@apache-superset/core';
+import { Bubble } from './components/Bubble';
+import { Panel } from './components/Panel';
+import { ExtensionErrorBoundary } from './components/ErrorBoundary';
+import { isOpen, subscribe } from './state';
+
+/**
+ * Root extension component. Mirrors module-state into React via `subscribe`
+ * so the bubble↔panel transition is driven by the same command handlers
+ * that external callers use (`core.chatbot__open`, `__close`, `__toggle`).
+ */
+export const ReferenceChatbot: React.FC = () => {
+ const [open, setOpenState] = useState<boolean>(isOpen());
+
+ useEffect(() => subscribe(setOpenState), []);
+
+ return (
+ <ExtensionErrorBoundary>
+ {open ? (
+ <Panel onClose={() => commands.executeCommand('core.chatbot__close')} />
+ ) : (
+ <Bubble onClick={() => commands.executeCommand('core.chatbot__open')} />
+ )}
+ </ExtensionErrorBoundary>
+ );
+};
diff --git a/extensions/chat/src/__tests__/activate.test.tsx b/extensions/chat/src/__tests__/activate.test.tsx
new file mode 100644
index 00000000000..99926c9706b
--- /dev/null
+++ b/extensions/chat/src/__tests__/activate.test.tsx
@@ -0,0 +1,144 @@
+/**
+ * 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 { commands } from '@apache-superset/core';
+import { registry, reset } from './sdkMock';
+import { activate, VIEW_ID, CHATBOT_LOCATION } from '../activate';
+import { isOpen } from '../state';
+import { streamReply } from '../streaming/mockStream';
+import {
+ registerActiveController,
+ unregisterActiveController,
+ abortAllActiveControllers,
+} from '../streaming/registry';
+
+beforeEach(() => {
+ reset();
+});
+
+test('registers one view at superset.chatbot and three chatbot commands', () => {
+ const disposable = activate();
+
+ try {
+ expect(registry.views.size).toBe(1);
+ const entry = registry.views.get(VIEW_ID);
+ expect(entry?.location).toBe(CHATBOT_LOCATION);
+ expect(entry?.view.icon).toBe('Bubble');
+
+ expect(Array.from(registry.commands.keys()).sort()).toEqual([
+ 'core.chatbot__close',
+ 'core.chatbot__open',
+ 'core.chatbot__toggle',
+ ]);
+ } finally {
+ disposable.dispose();
+ }
+});
+
+test('executeCommand drives open/close/toggle through module state', async () => {
+ const disposable = activate();
+ try {
+ expect(isOpen()).toBe(false);
+
+ await commands.executeCommand('core.chatbot__open');
+ expect(isOpen()).toBe(true);
+
+ await commands.executeCommand('core.chatbot__toggle');
+ expect(isOpen()).toBe(false);
+
+ await commands.executeCommand('core.chatbot__toggle');
+ expect(isOpen()).toBe(true);
+
+ await commands.executeCommand('core.chatbot__close');
+ expect(isOpen()).toBe(false);
+ } finally {
+ disposable.dispose();
+ }
+});
+
+test('disposing the master disposable unregisters view + commands', () => {
+ const disposable = activate();
+ expect(registry.views.size).toBe(1);
+ expect(registry.commands.size).toBe(3);
+
+ disposable.dispose();
+
+ expect(registry.views.size).toBe(0);
+ expect(registry.commands.size).toBe(0);
+});
+
+test('disposal is idempotent', () => {
+ const disposable = activate();
+ disposable.dispose();
+ expect(() => disposable.dispose()).not.toThrow();
+ expect(registry.views.size).toBe(0);
+});
+
+test('re-activate after dispose works (validates replace semantics)', () => {
+ const first = activate();
+ first.dispose();
+
+ const second = activate();
+ try {
+ expect(registry.views.size).toBe(1);
+ expect(registry.commands.size).toBe(3);
+ expect(isOpen()).toBe(false); // resetState() cleared open flag
+ } finally {
+ second.dispose();
+ }
+});
+
+test('aborting an active controller stops the stream cleanly', async () => {
+ const controller = new AbortController();
+ registerActiveController(controller);
+
+ const iter = streamReply('hello world', controller.signal);
+ const received: string[] = [];
+
+ const consume = (async () => {
+ for await (const tok of iter) received.push(tok);
+ })();
+
+ // Abort after a single tick — the iterator must return without throwing.
+ await new Promise(r => setTimeout(r, 50));
+ abortAllActiveControllers();
+ await expect(consume).resolves.toBeUndefined();
+
+ unregisterActiveController(controller);
+ expect(received.length).toBeLessThan(20); // would be ~20+ tokens if uncancelled
+});
+
+test('disposing the extension aborts any in-flight controller', async () => {
+ const disposable = activate();
+ const controller = new AbortController();
+ registerActiveController(controller);
+
+ const iter = streamReply('a longer prompt to ensure many tokens', controller.signal);
+ const consume = (async () => {
+ // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
+ for await (const _tok of iter) {
+ // drain
+ }
+ })();
+
+ await new Promise(r => setTimeout(r, 30));
+ disposable.dispose();
+
+ await expect(consume).resolves.toBeUndefined();
+ expect(controller.signal.aborted).toBe(true);
+});
diff --git a/extensions/chat/src/__tests__/sdkMock.ts b/extensions/chat/src/__tests__/sdkMock.ts
new file mode 100644
index 00000000000..60b089807b9
--- /dev/null
+++ b/extensions/chat/src/__tests__/sdkMock.ts
@@ -0,0 +1,119 @@
+/**
+ * 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.
+ */
+
+/**
+ * In-memory mock of `@apache-superset/core` for unit-testing the extension.
+ *
+ * Mirrors only the surfaces the reference chatbot consumes:
+ * - views.registerView returns a disposable that removes the view
+ * - commands.registerCommand / executeCommand round-trip handlers
+ * - sqlLab.getCurrentTab returns undefined (no SQL Lab in tests)
+ *
+ * The mock is intentionally observable: tests can read `registry.views` and
+ * `registry.commands` to assert contract compliance.
+ */
+
+import type { ReactElement } from 'react';
+
+type Provider = () => ReactElement;
+
+interface ViewDescriptor {
+ id: string;
+ name: string;
+ icon?: string;
+ description?: string;
+}
+
+interface DisposableLike {
+ dispose(): void;
+}
+
+interface RegisteredView {
+ view: ViewDescriptor;
+ location: string;
+ provider: Provider;
+}
+
+interface RegisteredCommand {
+ id: string;
+ title: string;
+ handler: (...args: any[]) => any;
+}
+
+export const registry = {
+ views: new Map<string, RegisteredView>(),
+ commands: new Map<string, RegisteredCommand>(),
+};
+
+export const reset = (): void => {
+ registry.views.clear();
+ registry.commands.clear();
+};
+
+export const views = {
+ registerView(
+ view: ViewDescriptor,
+ location: string,
+ provider: Provider,
+ ): DisposableLike {
+ registry.views.set(view.id, { view, location, provider });
+ return {
+ dispose: () => {
+ registry.views.delete(view.id);
+ },
+ };
+ },
+ getViews(location: string) {
+ return Array.from(registry.views.values())
+ .filter(v => v.location === location)
+ .map(v => v.view);
+ },
+};
+
+export const commands = {
+ registerCommand(
+ command: { id: string; title: string },
+ handler: (...args: any[]) => any,
+ ): DisposableLike {
+ registry.commands.set(command.id, {
+ id: command.id,
+ title: command.title,
+ handler,
+ });
+ return {
+ dispose: () => {
+ registry.commands.delete(command.id);
+ },
+ };
+ },
+ async executeCommand(id: string, ...rest: any[]): Promise<unknown> {
+ const cmd = registry.commands.get(id);
+ return cmd?.handler(...rest);
+ },
+ getCommands() {
+ return Array.from(registry.commands.values()).map(c => ({
+ id: c.id,
+ title: c.title,
+ }));
+ },
+};
+
+export const sqlLab = {
+ getCurrentTab: () => undefined,
+};
diff --git a/extensions/chat/src/activate.ts b/extensions/chat/src/activate.ts
new file mode 100644
index 00000000000..1c48ba7249f
--- /dev/null
+++ b/extensions/chat/src/activate.ts
@@ -0,0 +1,88 @@
+/**
+ * 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 '@apache-superset/core';
+import { ReferenceChatbot } from './ReferenceChatbot';
+import { registerChatbotCommands } from './commands';
+import { abortAllActiveControllers } from './streaming/registry';
+import { resetState } from './state';
+
+export const VIEW_ID = 'apache-superset.reference-chatbot';
+export const CHATBOT_LOCATION = 'superset.chatbot';
+
+interface DisposableLike {
+ dispose(): void;
+}
+
+/**
+ * Registers the reference chatbot and returns a single disposable that
+ * tears down everything it created. Idempotent across activate/dispose cycles.
+ *
+ * Cleanup order matters: stop in-flight streams first so listeners do not
+ * receive late tokens, then unregister commands (so user clicks during teardown
+ * become no-ops), then unregister the view (so the host's ChatbotMount unmounts
+ * the React tree), and finally reset module state.
+ *
+ * Returns a plain `{ dispose }` object rather than constructing a Disposable
+ * from the SDK — the SDK class is host-injected and only reliably available
+ * via window.superset at runtime, while plain disposable-likes work in both
+ * runtime and unit-test contexts.
+ *
+ * TODO(P1): when the host gains an async `deactivate(): Promise<void>` hook,
+ * wrap the master disposer to flush in-flight async work before returning.
+ */
+export const activate = (): DisposableLike => {
+ const commandDisposables = registerChatbotCommands();
+ const viewDisposable = views.registerView(
+ {
+ id: VIEW_ID,
+ name: 'Reference Chatbot',
+ icon: 'Bubble',
+ description: 'Validates the chatbot extension environment end-to-end.',
+ },
+ CHATBOT_LOCATION,
+ () => React.createElement(ReferenceChatbot),
+ );
+
+ let disposed = false;
+ return {
+ dispose() {
+ if (disposed) return;
+ disposed = true;
+ try {
+ abortAllActiveControllers();
+ } catch {
+ // streams are best-effort during teardown
+ }
+ commandDisposables.forEach(d => {
+ try {
+ d.dispose();
+ } catch {
+ // a single command failing to unregister must not block the rest
+ }
+ });
+ try {
+ viewDisposable.dispose();
+ } catch {
+ // ignore
+ }
+ resetState();
+ },
+ };
+};
diff --git a/extensions/chat/src/commands.ts b/extensions/chat/src/commands.ts
new file mode 100644
index 00000000000..4f19a8610b2
--- /dev/null
+++ b/extensions/chat/src/commands.ts
@@ -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.
+ */
+import { commands } from '@apache-superset/core';
+import { isOpen, setOpen } from './state';
+
+interface DisposableLike {
+ dispose(): void;
+}
+
+/**
+ * Registers the three chatbot intent commands and returns their disposables.
+ *
+ * TODO(P4): if/when the host pre-registers `core.chatbot__*` as host-owned
+ * intents that extensions implement instead of own, swap registerCommand for
+ * the implementation hook. The command ids stay the same so call sites do not
+ * change.
+ */
+export const registerChatbotCommands = (): DisposableLike[] => [
+ commands.registerCommand(
+ { id: 'core.chatbot__open', title: 'Open chatbot' },
+ () => setOpen(true),
+ ),
+ commands.registerCommand(
+ { id: 'core.chatbot__close', title: 'Close chatbot' },
+ () => setOpen(false),
+ ),
+ commands.registerCommand(
+ { id: 'core.chatbot__toggle', title: 'Toggle chatbot' },
+ () => setOpen(!isOpen()),
+ ),
+];
diff --git a/extensions/chat/src/components/Bubble.tsx b/extensions/chat/src/components/Bubble.tsx
new file mode 100644
index 00000000000..488ccc0cb4e
--- /dev/null
+++ b/extensions/chat/src/components/Bubble.tsx
@@ -0,0 +1,46 @@
+/**
+ * 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';
+
+interface Props {
+ onClick: () => void;
+}
+
+export const Bubble: React.FC<Props> = ({ onClick }) => (
+ <button
+ type="button"
+ onClick={onClick}
+ data-test="reference-chatbot-bubble"
+ aria-label="Open reference chatbot"
+ style={{
+ width: 56,
+ height: 56,
+ borderRadius: '50%',
+ border: 'none',
+ background: '#1f6feb',
+ color: '#fff',
+ fontSize: 24,
+ fontWeight: 600,
+ cursor: 'pointer',
+ boxShadow: '0 4px 14px rgba(0,0,0,0.18)',
+ }}
+ >
+ ?
+ </button>
+);
diff --git a/extensions/chat/src/components/ErrorBoundary.tsx b/extensions/chat/src/components/ErrorBoundary.tsx
new file mode 100644
index 00000000000..0d173a9068a
--- /dev/null
+++ b/extensions/chat/src/components/ErrorBoundary.tsx
@@ -0,0 +1,66 @@
+/**
+ * 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';
+
+interface State {
+ error: Error | null;
+}
+
+/**
+ * Defense-in-depth boundary. The host already wraps the mount in its own
+ * ErrorBoundary; this one keeps a panel crash from also bringing down the
+ * bubble next to it.
+ */
+export class ExtensionErrorBoundary extends React.Component<
+ React.PropsWithChildren<{}>,
+ State
+> {
+ state: State = { error: null };
+
+ static getDerivedStateFromError(error: Error): State {
+ return { error };
+ }
+
+ componentDidCatch(error: Error): void {
+ // eslint-disable-next-line no-console
+ console.error('[reference-chatbot] render error', error);
+ }
+
+ render() {
+ if (this.state.error) {
+ return (
+ <div
+ data-test="reference-chatbot-error"
+ style={{
+ padding: 12,
+ border: '1px solid #f5222d',
+ borderRadius: 6,
+ background: '#fff1f0',
+ color: '#a8071a',
+ fontSize: 12,
+ maxWidth: 320,
+ }}
+ >
+ Reference chatbot crashed: {this.state.error.message}
+ </div>
+ );
+ }
+ return <>{this.props.children}</>;
+ }
+}
diff --git a/extensions/chat/src/components/Panel.tsx b/extensions/chat/src/components/Panel.tsx
new file mode 100644
index 00000000000..895eb4ada47
--- /dev/null
+++ b/extensions/chat/src/components/Panel.tsx
@@ -0,0 +1,228 @@
+/**
+ * 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, { useCallback, useEffect, useRef, useState } from 'react';
+import { streamReply } from '../streaming/mockStream';
+import { getPageContext, PageContext, subscribeToPageChanges } from '../context/pageContext';
+import { registerActiveController, unregisterActiveController } from '../streaming/registry';
+
+interface Props {
+ onClose: () => void;
+}
+
+interface Message {
+ id: number;
+ from: 'user' | 'bot';
+ text: string;
+}
+
+let messageSeq = 0;
+
+export const Panel: React.FC<Props> = ({ onClose }) => {
+ const [input, setInput] = useState('');
+ const [messages, setMessages] = useState<Message[]>([]);
+ const [streaming, setStreaming] = useState(false);
+ const [pageContext, setPageContext] = useState<PageContext>(() => getPageContext());
+ const controllerRef = useRef<AbortController | null>(null);
+
+ useEffect(
+ () => subscribeToPageChanges(() => setPageContext(getPageContext())),
+ [],
+ );
+
+ useEffect(
+ () => () => {
+ // Component unmount cancels any in-flight stream.
+ controllerRef.current?.abort();
+ },
+ [],
+ );
+
+ const send = useCallback(async () => {
+ const prompt = input.trim();
+ if (!prompt || streaming) return;
+ setInput('');
+ const userMsg: Message = { id: ++messageSeq, from: 'user', text: prompt };
+ const botMsg: Message = { id: ++messageSeq, from: 'bot', text: '' };
+ setMessages(prev => [...prev, userMsg, botMsg]);
+ setStreaming(true);
+
+ const controller = new AbortController();
+ controllerRef.current = controller;
+ registerActiveController(controller);
+
+ try {
+ for await (const token of streamReply(prompt, controller.signal)) {
+ setMessages(prev =>
+ prev.map(m => (m.id === botMsg.id ? { ...m, text: m.text + token } : m)),
+ );
+ }
+ } finally {
+ unregisterActiveController(controller);
+ controllerRef.current = null;
+ setStreaming(false);
+ }
+ }, [input, streaming]);
+
+ const cancel = useCallback(() => {
+ controllerRef.current?.abort();
+ }, []);
+
+ return (
+ <div
+ data-test="reference-chatbot-panel"
+ style={{
+ width: 360,
+ maxHeight: 480,
+ display: 'flex',
+ flexDirection: 'column',
+ background: '#fff',
+ border: '1px solid #d9d9d9',
+ borderRadius: 8,
+ boxShadow: '0 8px 24px rgba(0,0,0,0.18)',
+ overflow: 'hidden',
+ fontSize: 13,
+ }}
+ >
+ <header
+ style={{
+ padding: '8px 12px',
+ background: '#1f6feb',
+ color: '#fff',
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ }}
+ >
+ <span>Reference Chatbot</span>
+ <button
+ type="button"
+ onClick={onClose}
+ aria-label="Close chatbot"
+ data-test="reference-chatbot-close"
+ style={{
+ background: 'transparent',
+ border: 'none',
+ color: '#fff',
+ fontSize: 16,
+ cursor: 'pointer',
+ }}
+ >
+ ×
+ </button>
+ </header>
+
+ <div
+ data-test="reference-chatbot-context"
+ style={{
+ padding: '6px 12px',
+ background: '#f6f8fa',
+ borderBottom: '1px solid #eaecef',
+ fontFamily: 'monospace',
+ fontSize: 11,
+ color: '#57606a',
+ wordBreak: 'break-all',
+ }}
+ >
+ page: {pageContext.pageType}
+ {pageContext.sqlLab ? ` · tab: ${pageContext.sqlLab.title}` : ''}
+ </div>
+
+ <div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
+ {messages.length === 0 && (
+ <p style={{ color: '#8c8c8c' }}>
+ Ask anything — replies are canned tokens streamed by the reference extension.
+ </p>
+ )}
+ {messages.map(m => (
+ <div
+ key={m.id}
+ data-test={`reference-chatbot-msg-${m.from}`}
+ style={{
+ margin: '6px 0',
+ textAlign: m.from === 'user' ? 'right' : 'left',
+ }}
+ >
+ <span
+ style={{
+ display: 'inline-block',
+ padding: '4px 8px',
+ borderRadius: 6,
+ background: m.from === 'user' ? '#1f6feb' : '#eef0f3',
+ color: m.from === 'user' ? '#fff' : '#1f2328',
+ maxWidth: '85%',
+ whiteSpace: 'pre-wrap',
+ }}
+ >
+ {m.text || '…'}
+ </span>
+ </div>
+ ))}
+ </div>
+
+ <footer
+ style={{
+ padding: 8,
+ borderTop: '1px solid #eaecef',
+ display: 'flex',
+ gap: 6,
+ }}
+ >
+ <input
+ aria-label="Chat input"
+ data-test="reference-chatbot-input"
+ value={input}
+ onChange={e => setInput(e.target.value)}
+ onKeyDown={e => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ send();
+ }
+ }}
+ placeholder="Type a message"
+ style={{
+ flex: 1,
+ padding: '4px 8px',
+ border: '1px solid #d9d9d9',
+ borderRadius: 4,
+ }}
+ />
+ {streaming ? (
+ <button
+ type="button"
+ onClick={cancel}
+ data-test="reference-chatbot-cancel"
+ style={{ padding: '4px 10px' }}
+ >
+ Stop
+ </button>
+ ) : (
+ <button
+ type="button"
+ onClick={send}
+ data-test="reference-chatbot-send"
+ disabled={!input.trim()}
+ style={{ padding: '4px 10px' }}
+ >
+ Send
+ </button>
+ )}
+ </footer>
+ </div>
+ );
+};
diff --git a/extensions/chat/src/context/pageContext.ts b/extensions/chat/src/context/pageContext.ts
new file mode 100644
index 00000000000..bc6bc92c060
--- /dev/null
+++ b/extensions/chat/src/context/pageContext.ts
@@ -0,0 +1,114 @@
+/**
+ * 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.
+ */
+
+/**
+ * Single integration seam for the P3 namespaces.
+ *
+ * Each surface namespace is consumed via a try/catch — the host may ship a
+ * version where a namespace function is declared but not yet implemented at
+ * runtime, and the reference extension must keep working in that case. As
+ * each namespace lights up on the host, that branch starts returning real
+ * data without any change here.
+ *
+ * Route inference is the fallback when navigation.getPageType() is absent.
+ */
+
+import * as core from '@apache-superset/core';
+
+export type PageType =
+ | 'home'
+ | 'dashboard'
+ | 'chart'
+ | 'sqllab'
+ | 'dataset'
+ | 'unknown';
+
+export interface PageContext {
+ pageType: PageType;
+ dashboard?: unknown;
+ chart?: unknown;
+ dataset?: unknown;
+ sqlLab?: { tabId: string; title: string };
+ href: string;
+}
+
+const tryCall = <T>(fn: () => T | undefined): T | undefined => {
+ try {
+ return fn();
+ } catch {
+ return undefined;
+ }
+};
+
+const inferPageType = (pathname: string): PageType => {
+ if (pathname.startsWith('/sqllab')) return 'sqllab';
+ if (
+ pathname.startsWith('/superset/dashboard') ||
+ pathname.startsWith('/dashboard')
+ )
+ return 'dashboard';
+ if (pathname.startsWith('/explore') || pathname.startsWith('/chart'))
+ return 'chart';
+ if (pathname.startsWith('/tablemodelview') || pathname.startsWith('/dataset'))
+ return 'dataset';
+ if (pathname === '/' || pathname.startsWith('/superset/welcome'))
+ return 'home';
+ return 'unknown';
+};
+
+const readSqlLabTab = (): PageContext['sqlLab'] => {
+ const tab = tryCall(() => (core as any).sqlLab?.getCurrentTab?.());
+ return tab ? { tabId: tab.id, title: tab.title } : undefined;
+};
+
+const readPageType = (pathname: string): PageType => {
+ const fromNav = tryCall(() => (core as any).navigation?.getPageType?.());
+ return (fromNav as PageType | undefined) ?? inferPageType(pathname);
+};
+
+/**
+ * Subscribe to page changes. Uses navigation.onDidChangePage when available,
+ * falls back to popstate for hosts without the navigation namespace.
+ * Returns a cleanup function.
+ */
+export const subscribeToPageChanges = (onChange: () => void): (() => void) => {
+ const nav = tryCall(() => (core as any).navigation);
+ if (nav?.onDidChangePage) {
+ const sub = nav.onDidChangePage(onChange);
+ return () => sub.dispose();
+ }
+ window.addEventListener('popstate', onChange);
+ return () => window.removeEventListener('popstate', onChange);
+};
+
+export const getPageContext = (): PageContext => {
+ const { pathname, href } =
+ typeof window !== 'undefined'
+ ? window.location
+ : { pathname: '', href: '' };
+
+ return {
+ pageType: readPageType(pathname),
+ dashboard: tryCall(() => (core as any).dashboard?.getCurrentDashboard?.()),
+ chart: tryCall(() => (core as any).explore?.getCurrentChart?.()),
+ dataset: tryCall(() => (core as any).dataset?.getCurrentDataset?.()),
+ sqlLab: readSqlLabTab(),
+ href,
+ };
+};
diff --git a/extensions/chat/src/index.tsx b/extensions/chat/src/index.tsx
new file mode 100644
index 00000000000..688a5bf283a
--- /dev/null
+++ b/extensions/chat/src/index.tsx
@@ -0,0 +1,30 @@
+/**
+ * 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.
+ */
+
+/**
+ * Module Federation entry. The host loads `./index` and invokes the factory;
+ * the side effect below registers the view + commands. The host's loader
+ * intercepts registerView calls to collect disposables for deactivation, so
+ * returning the master Disposable here is also captured by the test harness
+ * for direct assertion.
+ */
+
+import { activate } from './activate';
+
+export const disposable = activate();
diff --git a/extensions/chat/src/state.ts b/extensions/chat/src/state.ts
new file mode 100644
index 00000000000..5b4a00780d7
--- /dev/null
+++ b/extensions/chat/src/state.ts
@@ -0,0 +1,57 @@
+/**
+ * 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.
+ */
+
+/**
+ * Module-scoped open/closed state plus a tiny emitter the UI subscribes to.
+ *
+ * Lives entirely inside the extension — never reaches into the host store.
+ * Reset on dispose so re-activation starts cleanly.
+ */
+
+export type OpenStateListener = (open: boolean) => void;
+
+let open = false;
+const listeners = new Set<OpenStateListener>();
+
+export const isOpen = (): boolean => open;
+
+export const setOpen = (next: boolean): void => {
+ if (next === open) return;
+ open = next;
+ listeners.forEach(fn => {
+ try {
+ fn(open);
+ } catch {
+ // A listener throwing must not block other listeners or flip our state back.
+ }
+ });
+};
+
+export const subscribe = (fn: OpenStateListener): (() => void) => {
+ listeners.add(fn);
+ return () => {
+ listeners.delete(fn);
+ };
+};
+
+/** Drains listeners and resets state. Called from the master Disposable. */
+export const resetState = (): void => {
+ open = false;
+ listeners.clear();
+};
diff --git a/extensions/chat/src/streaming/mockStream.ts b/extensions/chat/src/streaming/mockStream.ts
new file mode 100644
index 00000000000..a72cf0d6435
--- /dev/null
+++ b/extensions/chat/src/streaming/mockStream.ts
@@ -0,0 +1,73 @@
+/**
+ * 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.
+ */
+
+/**
+ * Mock streaming reply used to validate stream teardown semantics.
+ *
+ * The reference chatbot is environment-validation only — there is no LLM.
+ * This iterator yields canned tokens on a timer and exits cleanly when its
+ * AbortSignal is fired. Disposal of the extension aborts any in-flight
+ * controller, which is the contract that proves async cancellation works.
+ */
+
+const TICK_MS = 40;
+
+const buildReply = (prompt: string): string => {
+ const trimmed = prompt.trim();
+ if (!trimmed) {
+ return 'Reference chatbot online. Send a message to validate streaming.';
+ }
+ return (
+ `[reference-chatbot] received "${trimmed}". ` +
+ 'Streaming token-by-token to validate cancellation and teardown.'
+ );
+};
+
+const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
+ new Promise((resolve, reject) => {
+ if (signal.aborted) {
+ reject(new DOMException('aborted', 'AbortError'));
+ return;
+ }
+ const timer = setTimeout(() => {
+ signal.removeEventListener('abort', onAbort);
+ resolve();
+ }, ms);
+ const onAbort = () => {
+ clearTimeout(timer);
+ reject(new DOMException('aborted', 'AbortError'));
+ };
+ signal.addEventListener('abort', onAbort, { once: true });
+ });
+
+export async function* streamReply(
+ prompt: string,
+ signal: AbortSignal,
+): AsyncIterableIterator<string> {
+ const tokens = buildReply(prompt).split(/(\s+)/);
+ for (const token of tokens) {
+ if (signal.aborted) return;
+ try {
+ await sleep(TICK_MS, signal);
+ } catch {
+ return;
+ }
+ yield token;
+ }
+}
diff --git a/extensions/chat/src/streaming/registry.ts b/extensions/chat/src/streaming/registry.ts
new file mode 100644
index 00000000000..fca0f2fdbc8
--- /dev/null
+++ b/extensions/chat/src/streaming/registry.ts
@@ -0,0 +1,46 @@
+/**
+ * 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.
+ */
+
+/**
+ * Module-scoped registry of in-flight stream AbortControllers.
+ *
+ * Lets the master Disposable abort any running stream even when the panel
+ * is unmounted by a route change or by re-activation of the extension.
+ */
+
+const active = new Set<AbortController>();
+
+export const registerActiveController = (c: AbortController): void => {
+ active.add(c);
+};
+
+export const unregisterActiveController = (c: AbortController): void => {
+ active.delete(c);
+};
+
+export const abortAllActiveControllers = (): void => {
+ active.forEach(c => {
+ try {
+ c.abort();
+ } catch {
+ // ignore — abort() should not throw, but stay defensive.
+ }
+ });
+ active.clear();
+};
diff --git a/extensions/chat/tsconfig.json b/extensions/chat/tsconfig.json
new file mode 100644
index 00000000000..0edb6a5ac3f
--- /dev/null
+++ b/extensions/chat/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "es2019",
+ "module": "esnext",
+ "moduleResolution": "node",
+ "jsx": "react",
+ "strict": true,
+ "noImplicitAny": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "lib": ["dom", "es2019"]
+ },
+ "include": ["src"]
+}
diff --git a/extensions/chat/tsconfig.test.json b/extensions/chat/tsconfig.test.json
new file mode 100644
index 00000000000..3c04af57683
--- /dev/null
+++ b/extensions/chat/tsconfig.test.json
@@ -0,0 +1,15 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@apache-superset/core": ["src/__tests__/sdkMock.ts"]
+ },
+ "typeRoots": [
+ "./node_modules/@types",
+ "../../superset-frontend/node_modules/@types"
+ ],
+ "types": ["jest", "node"]
+ },
+ "include": ["src"]
+}
diff --git a/extensions/chat/webpack.config.js b/extensions/chat/webpack.config.js
new file mode 100644
index 00000000000..13a615d6596
--- /dev/null
+++ b/extensions/chat/webpack.config.js
@@ -0,0 +1,70 @@
+/**
+ * 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.
+ */
+const path = require('path');
+const { ModuleFederationPlugin } = require('webpack').container;
+const packageConfig = require('./package.json');
+const extensionConfig = require('./extension.json');
+
+module.exports = (env, argv) => {
+ const isProd = argv.mode === 'production';
+
+ return {
+ entry: isProd ? {} : './src/index.tsx',
+ mode: isProd ? 'production' : 'development',
+ devtool: isProd ? false : 'eval-cheap-module-source-map',
+ devServer: {
+ port: 3030,
+ headers: { 'Access-Control-Allow-Origin': '*' },
+ },
+ output: {
+ clean: true,
+ filename: isProd ? undefined : '[name].[contenthash].js',
+ chunkFilename: '[name].[contenthash].js',
+ path: path.resolve(__dirname, 'dist'),
+ publicPath: `/api/v1/extensions/${extensionConfig.publisher}/${extensionConfig.name}/`,
+ },
+ resolve: { extensions: ['.ts', '.tsx', '.js', '.jsx'] },
+ externalsType: 'window',
+ externals: { '@apache-superset/core': 'superset' },
+ module: {
+ rules: [
+ { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/ },
+ ],
+ },
+ plugins: [
+ new ModuleFederationPlugin({
+ name: 'apacheSuperset_referenceChatbot',
+ filename: 'remoteEntry.[contenthash].js',
+ exposes: { './index': './src/index.tsx' },
+ shared: {
+ react: {
+ singleton: true,
+ requiredVersion: packageConfig.peerDependencies.react,
+ import: false,
+ },
+ 'react-dom': {
+ singleton: true,
+ requiredVersion: packageConfig.peerDependencies['react-dom'],
+ import: false,
+ },
+ },
+ }),
+ ],
+ };
+};