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 <[email protected]>
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 <[email protected]>
---
 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,
+          },
+        },
+      }),
+    ],
+  };
+};

Reply via email to