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>
);
};