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

rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 9612437a9c8 fix(extensions): preserve chat state across display modes 
(#44490)
9612437a9c8 is described below

commit 9612437a9c8de2cad3f5aebfa728ec0663a9e3b0
Author: Bexultan <[email protected]>
AuthorDate: Wed Sep 23 06:39:29 2026 +0500

    fix(extensions): preserve chat state across display modes (#44490)
    
    Co-authored-by: Bexultan Mustafin <[email protected]>
---
 .../extensions/extension-points/chat.md            |   4 +
 superset-frontend/src/core/chat/ChatHost.tsx       |  20 ++--
 superset-frontend/src/views/App.test.tsx           | 125 +++++++++++++++++++++
 superset-frontend/src/views/App.tsx                |  28 ++++-
 4 files changed, 165 insertions(+), 12 deletions(-)

diff --git a/docs/developer_docs/extensions/extension-points/chat.md 
b/docs/developer_docs/extensions/extension-points/chat.md
index 6faf493d2a2..154d17115ab 100644
--- a/docs/developer_docs/extensions/extension-points/chat.md
+++ b/docs/developer_docs/extensions/extension-points/chat.md
@@ -46,6 +46,10 @@ The host supports two display modes, switchable by the user 
or the extension at
 
 The user's last selected mode and open/closed state are persisted across page 
reloads.
 
+Switching display modes keeps the same panel mounted, preserving its 
conversation,
+draft, expanded tool results, and in-flight UI state. Closing the chat still
+unmounts the panel; extensions remain responsible for their cleanup on unmount.
+
 ## Registering a Chat
 
 Call `chat.registerChat` from your extension's entry point with a descriptor, 
a trigger factory, and a panel factory:
diff --git a/superset-frontend/src/core/chat/ChatHost.tsx 
b/superset-frontend/src/core/chat/ChatHost.tsx
index 73ba519f694..bdecdff139a 100644
--- a/superset-frontend/src/core/chat/ChatHost.tsx
+++ b/superset-frontend/src/core/chat/ChatHost.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { type ComponentType, useRef } from 'react';
+import { type ComponentType, type ReactNode, useRef } from 'react';
 import { t } from '@apache-superset/core/translation';
 import { logging } from '@apache-superset/core/utils';
 import { css, useTheme } from '@apache-superset/core/theme';
@@ -88,7 +88,7 @@ export const ChatPanelHost = () => {
  * floating panel overlay. The trigger is always visible when a chat is
  * registered; the panel overlay is suppressed in panel mode.
  */
-export const ChatFloatingHost = () => {
+export const ChatFloatingHost = ({ children }: { children?: ReactNode }) => {
   const theme = useTheme();
   const { open: panelOpen, mode, chat, trigger, panel } = useChat();
   const onError = useCrashNotifier(chat?.id);
@@ -116,13 +116,15 @@ export const ChatFloatingHost = () => {
         Separate boundaries so a crashing panel cannot take the trigger down
         with it — the trigger is the user's only way back.
       */}
-      {panelOpen && mode !== 'panel' && (
-        <ChatBoundary
-          key={`panel-${chat.id}`}
-          component={panel}
-          onError={onError}
-        />
-      )}
+      {panelOpen &&
+        mode !== 'panel' &&
+        (children ?? (
+          <ChatBoundary
+            key={`panel-${chat.id}`}
+            component={panel}
+            onError={onError}
+          />
+        ))}
       <ChatBoundary
         key={`trigger-${chat.id}`}
         component={trigger}
diff --git a/superset-frontend/src/views/App.test.tsx 
b/superset-frontend/src/views/App.test.tsx
new file mode 100644
index 00000000000..b1b67d363e8
--- /dev/null
+++ b/superset-frontend/src/views/App.test.tsx
@@ -0,0 +1,125 @@
+/**
+ * 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 { type ReactNode, useEffect, useState } from 'react';
+import { act, fireEvent, render, screen } from 'spec/helpers/testing-library';
+import { chat } from 'src/core/chat';
+import ChatProvider from 'src/core/chat/ChatProvider';
+import App from './App';
+
+jest.mock('src/setup/setupApp', () => jest.fn());
+jest.mock('src/setup/setupPlugins', () => jest.fn());
+jest.mock('src/setup/setupCodeOverrides', () => jest.fn());
+jest.mock('@superset-ui/core/components/ThemedAgGridReact', () => ({
+  setupAGGridModules: jest.fn(),
+}));
+jest.mock('src/features/home/Menu', () => () => null);
+jest.mock('src/components/MessageToasts/ToastContainer', () => () => null);
+jest.mock(
+  'src/extensions/ExtensionsStartup',
+  () =>
+    ({ children }: { children: ReactNode }) =>
+      children,
+);
+jest.mock('./RootContextProviders', () => ({
+  RootContextProviders: ({ children }: { children: ReactNode }) => children,
+}));
+jest.mock('./routes', () => ({
+  routes: [],
+  isFrontendRoute: () => true,
+}));
+jest.mock('src/utils/getBootstrapData', () => {
+  const original = jest.requireActual('src/utils/getBootstrapData');
+  return {
+    ...original,
+    __esModule: true,
+    default: () => ({
+      ...original.default(),
+      user: { userId: 1, username: 'admin', isAnonymous: false },
+    }),
+  };
+});
+
+test('changing chat display mode preserves the mounted panel and in-flight 
state', () => {
+  ChatProvider.getInstance().reset();
+  window.featureFlags = { ENABLE_EXTENSIONS: true };
+  const stream = new EventTarget();
+  const mounted = jest.fn();
+  const unmounted = jest.fn();
+  const Panel = () => {
+    const [draft, setDraft] = useState('');
+    const [answer, setAnswer] = useState('');
+    useEffect(() => {
+      mounted();
+      const receive = () => setAnswer(previous => `${previous}chunk `);
+      stream.addEventListener('chunk', receive);
+      return () => {
+        unmounted();
+        stream.removeEventListener('chunk', receive);
+      };
+    }, []);
+    return (
+      <>
+        <input
+          aria-label="Draft"
+          value={draft}
+          onChange={event => setDraft(event.target.value)}
+        />
+        <p data-test="streamed-answer">{answer}</p>
+        <details>
+          <summary>Tool result</summary>
+          <pre>Rows: 3</pre>
+        </details>
+      </>
+    );
+  };
+  const registration = chat.registerChat(
+    { id: 'stateful.chat', name: 'Stateful chat' },
+    () => <button type="button">Open chat</button>,
+    Panel,
+  );
+  const { unmount } = render(<App />);
+  act(() => chat.open());
+  const input = screen.getByRole('textbox', { name: 'Draft' });
+  const answer = screen.getByTestId('streamed-answer');
+  const details = screen.getByText('Tool result').closest('details');
+  fireEvent.change(input, { target: { value: 'Unsent question' } });
+  fireEvent.click(screen.getByText('Tool result'));
+  act(() => stream.dispatchEvent(new Event('chunk')));
+
+  for (const mode of ['panel', 'floating', 'panel', 'floating'] as const) {
+    act(() => chat.setDisplayMode(mode));
+    expect(screen.getByRole('textbox', { name: 'Draft' })).toBe(input);
+    expect(input).toHaveValue('Unsent question');
+    expect(screen.getByTestId('streamed-answer')).toBe(answer);
+    expect(answer).toHaveTextContent('chunk');
+    expect(details).toHaveAttribute('open');
+    expect(mounted).toHaveBeenCalledTimes(1);
+    expect(unmounted).not.toHaveBeenCalled();
+  }
+  act(() => stream.dispatchEvent(new Event('chunk')));
+  expect(answer).toHaveTextContent('chunk chunk');
+  act(() => chat.close());
+  expect(unmounted).toHaveBeenCalledTimes(1);
+  expect(
+    screen.queryByRole('textbox', { name: 'Draft' }),
+  ).not.toBeInTheDocument();
+  unmount();
+  registration.dispose();
+  window.featureFlags = {};
+});
diff --git a/superset-frontend/src/views/App.tsx 
b/superset-frontend/src/views/App.tsx
index 03611db3590..4f1590ccfdf 100644
--- a/superset-frontend/src/views/App.tsx
+++ b/superset-frontend/src/views/App.tsx
@@ -169,6 +169,17 @@ const AppContent = ({
   const { open: panelOpen, mode, chat } = useChat();
   const hasChatExtension = chatExtensionsEnabled && !!chat;
   const isPanelOpen = hasChatExtension && mode === 'panel' && panelOpen;
+  // Keep the provider mounted while its DOM moves between display modes.
+  const chatPortalNode = useMemo(
+    () =>
+      createHtmlPortalNode({
+        attributes: {
+          style:
+            'display: flex; flex-direction: column; height: 100%; min-height: 
0;',
+        },
+      }),
+    [],
+  );
 
   const [storedWidth, setStoredWidth] = useStoredSidebarWidth(
     'chat:panel',
@@ -214,13 +225,17 @@ const AppContent = ({
     >
       <Splitter.Panel>{layoutContent}</Splitter.Panel>
       <Splitter.Panel size={storedWidth} min={CHAT_PANEL_MIN_WIDTH}>
-        <ChatPanelHost />
+        <OutPortal node={chatPortalNode} />
       </Splitter.Panel>
     </Splitter>
   ) : (
     <>
       {layoutContent}
-      {hasChatExtension && <ChatFloatingHost />}
+      {hasChatExtension && (
+        <ChatFloatingHost>
+          <OutPortal node={chatPortalNode} />
+        </ChatFloatingHost>
+      )}
     </>
   );
 
@@ -230,7 +245,14 @@ const AppContent = ({
         data={bootstrapData.common.menu_data}
         isFrontendRoute={isFrontendRoute}
       />
-      <ExtensionsStartup>{content}</ExtensionsStartup>
+      <ExtensionsStartup>
+        {hasChatExtension && panelOpen && (
+          <InPortal node={chatPortalNode}>
+            <ChatPanelHost />
+          </InPortal>
+        )}
+        {content}
+      </ExtensionsStartup>
     </Flex>
   );
 };

Reply via email to