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

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 706f50891 fix(ai): isolate persisted conversations in Mock mode (#7128)
706f50891 is described below

commit 706f508917db3312884ebfc46c78495dc0f1a501
Author: zmuxuny <[email protected]>
AuthorDate: Tue Oct 27 03:27:40 2026 -0700

    fix(ai): isolate persisted conversations in Mock mode (#7128)
    
    Mock mode is a demo/offline surface, but it still loaded and mutated 
persisted AI
    conversations: a deep link to /ai/c/<id> attached a real conversation, the 
history
    drawer mounted its live query and delete actions, and a home-page draft was
    auto-sent, creating a row in the real store.
    
    Gate all three on the mode. The history modal takes a disabled prop and 
renders
    an InfoBanner explaining that saved conversations are unavailable in mock 
mode
    instead of mounting the panel, so no query or action is reachable; the draft
    handoff takes sendEnabled and consumes the draft locally without creating a
    conversation or sending it; and the deep link is not attached. Adds the
    ai.mockConversationsUnavailable key in zh and en.
---
 web/src/i18n/translations.ts                       |  4 +
 web/src/pages/ai/__tests__/AiPage.test.tsx         | 97 ++++++++++++++++++++++
 .../pages/ai/components/ConversationListModal.tsx  | 21 +++--
 web/src/pages/ai/hooks/useDraftHandoff.ts          | 11 ++-
 web/src/pages/ai/index.tsx                         |  6 +-
 5 files changed, 132 insertions(+), 7 deletions(-)

diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 3088a7b38..98773649f 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1843,6 +1843,10 @@ const translations: Record<string, Record<Lang, string>> 
= {
     zh: '切换到真实数据模式并配置 LLM Provider 后,才会加载模型、工具目录和对话能力。',
     en: 'Models, the tool catalog and chat capabilities are loaded only after 
you switch to real data mode and configure an LLM provider.',
   },
+  'ai.mockConversationsUnavailable': {
+    zh: 'Mock 模式不加载或修改已保存的 AI 会话,请切换到真实数据模式后使用。',
+    en: 'Saved AI conversations are not loaded or changed in mock mode; switch 
to real data mode to use them.',
+  },
   'ai.mockToolsUnavailable': {
     zh: 'Mock 模式不加载 AI 工具目录,请切换到真实数据模式后使用。',
     en: 'The AI tool catalog is not loaded in mock mode; switch to real data 
mode to use it.',
diff --git a/web/src/pages/ai/__tests__/AiPage.test.tsx 
b/web/src/pages/ai/__tests__/AiPage.test.tsx
index efc3f7844..20890d1c2 100644
--- a/web/src/pages/ai/__tests__/AiPage.test.tsx
+++ b/web/src/pages/ai/__tests__/AiPage.test.tsx
@@ -21,6 +21,7 @@ import userEvent from '@testing-library/user-event';
 import { App } from 'antd';
 import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 
'react-router-dom';
 import { LangProvider } from '../../../i18n/LangContext';
+import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
 import { attachRunStream, executeTool, listTools, openRunStream } from 
'../../../api/ai';
 import {
   createConversation,
@@ -716,6 +717,102 @@ describe('AiPage', () => {
     expect(getLlmModels).not.toHaveBeenCalled();
   });
 
+  it.each([
+    { fromHome: false, lang: 'zh', title: 'AI 对话历史' },
+    { fromHome: true, lang: 'zh', title: 'AI 对话历史' },
+    { fromHome: false, lang: 'en', title: 'AI conversation history' },
+    { fromHome: true, lang: 'en', title: 'AI conversation history' },
+  ])(
+    'doesNotLoadRealConversationHistoryInMockModeTest ($lang, route intent: 
$fromHome)',
+    async ({ fromHome, lang, title }) => {
+      dataModeMocks.useMock = true;
+      localStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
+      vi.mocked(listConversations).mockResolvedValue({
+        items: [CONVERSATION_7],
+        total: 1,
+        page: 1,
+        size: 20,
+      });
+      renderPage(fromHome ? { historyIntent: 'open' } : undefined);
+      if (!fromHome) {
+        await userEvent.setup().click(screen.getByRole('button', { name: title 
}));
+      }
+      const dialog = await screen.findByRole('dialog', { name: title });
+      await act(async () => {});
+
+      expect(listConversations).not.toHaveBeenCalled();
+      
expect(within(dialog).queryByTestId('ai-conversation-link-7')).not.toBeInTheDocument();
+      
expect(within(dialog).queryByTestId('ai-conversation-page-delete')).not.toBeInTheDocument();
+      
expect(within(dialog).getByTestId('ai-mock-history-disabled')).toHaveTextContent(
+        lang === 'zh'
+          ? 'Mock 模式不加载或修改已保存的 AI 会话,请切换到真实数据模式后使用。'
+          : 'Saved AI conversations are not loaded or changed in mock mode; 
switch to real data mode to use them.',
+      );
+    },
+  );
+
+  it('doesNotReadOrAttachARealConversationDeepLinkInMockModeTest', async () => 
{
+    dataModeMocks.useMock = true;
+    vi.mocked(getConversationTimeline).mockResolvedValue(
+      timelinePage([item(1, { type: 'text', text: 'real conversation content' 
})], {
+        id: 41,
+        status: 'RUNNING',
+      }),
+    );
+    vi.mocked(attachRunStream).mockReturnValue(new Promise(() => {}));
+    renderRouted('/ai/c/7');
+    await screen.findByTestId('ai-mock-disabled');
+    await act(async () => {});
+
+    expect.soft(getConversationTimeline).not.toHaveBeenCalled();
+    expect.soft(attachRunStream).not.toHaveBeenCalled();
+    expect(screen.queryByText('real conversation 
content')).not.toBeInTheDocument();
+    
expect(screen.getByTestId('ai-send-stop-button')).toHaveAttribute('data-state', 
'send');
+    expect(stopRun).not.toHaveBeenCalled();
+  });
+
+  it.each(['/ai', '/ai/c/7'])(
+    'keepsTheHomeDraftWithoutCreatingARealConversationInMockModeTest (%s)',
+    async (path) => {
+      dataModeMocks.useMock = true;
+      renderRouted(path, { prompt: 'Keep this mock draft', mode: 'diagnose', 
enhance: true });
+      await waitFor(() => expect(readProbeLocation().state).toBeNull());
+
+      expect.soft(createConversation).not.toHaveBeenCalled();
+      expect.soft(listInstances).not.toHaveBeenCalled();
+      expect.soft(openRunStream).not.toHaveBeenCalled();
+      expect.soft(readProbeLocation().pathname).toBe(path);
+      expect(screen.getByPlaceholderText(PLACEHOLDER)).toHaveValue('Keep this 
mock draft');
+      expect(sessionStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY)).toBe('Keep 
this mock draft');
+    },
+  );
+
+  it('onlySendsTheRetainedMockDraftAfterManualSubmitInRealModeTest', async () 
=> {
+    dataModeMocks.useMock = true;
+    const view = renderPage({ prompt: 'Submit this after switching to real 
mode' });
+    await waitFor(() => expect(readProbeLocation().state).toBeNull());
+    expect(createConversation).not.toHaveBeenCalled();
+    view.unmount();
+
+    // MainLayout changes modes with a full reload. The draft survives it 
locally, but its
+    // consumed route state must not silently turn that reload into a live 
backend operation.
+    dataModeMocks.useMock = false;
+    vi.mocked(openRunStream).mockReturnValue(new Promise(() => {}));
+    renderPage();
+    await waitFor(() => expect(getLlmModels).toHaveBeenCalled());
+    const input = screen.getByPlaceholderText(PLACEHOLDER);
+    expect(input).toHaveValue('Submit this after switching to real mode');
+    expect(createConversation).not.toHaveBeenCalled();
+    expect(openRunStream).not.toHaveBeenCalled();
+
+    fireEvent.keyDown(input, { key: 'Enter' });
+    await waitFor(() => expect(openRunStream).toHaveBeenCalledTimes(1));
+    expect(createConversation).toHaveBeenCalledTimes(1);
+    expect(vi.mocked(openRunStream).mock.calls[0][1].message).toBe(
+      'Submit this after switching to real mode',
+    );
+  });
+
   it('doesNotLoadTheLlmRuntimeInMockModeAndDisablesTheComposerTest', async () 
=> {
     dataModeMocks.useMock = true;
     const user = userEvent.setup();
diff --git a/web/src/pages/ai/components/ConversationListModal.tsx 
b/web/src/pages/ai/components/ConversationListModal.tsx
index 07db73e0a..4a10e54f0 100644
--- a/web/src/pages/ai/components/ConversationListModal.tsx
+++ b/web/src/pages/ai/components/ConversationListModal.tsx
@@ -38,6 +38,7 @@ import { useConversationList } from 
'../hooks/useConversationList';
 import { formatUtcDateTime } from '../../../utils/format';
 import { tableScrollX } from '../../../utils/table';
 import ModelBadge from './ModelBadge';
+import InfoBanner from '../../../components/InfoBanner';
 
 /**
  * Server-paged conversation history.
@@ -148,6 +149,8 @@ const CHAT_MODE_KEYS = new Set(['chat', 'diagnose', 
'manage', 'query']);
 
 export interface ConversationListModalProps {
   open: boolean;
+  /** Mock mode: explain why history is unavailable without mounting its live 
query or actions. */
+  disabled?: boolean;
   onClose: () => void;
   /** Conversation on screen; marked as current and not offered as a 
navigation target. */
   activeConversationId?: number | null;
@@ -514,6 +517,7 @@ const ConversationListPanel = ({
 const ConversationListModal = ({
   open,
   onClose,
+  disabled = false,
   activeConversationId = null,
   onSelect,
   onActiveDeleted,
@@ -547,11 +551,18 @@ const ConversationListModal = ({
       width={TABLE_SCROLL_X + MODAL_BODY_PADDING + VERTICAL_SCROLLBAR_WIDTH}
       destroyOnHidden
     >
-      <ConversationListPanel
-        activeConversationId={activeConversationId}
-        onSelect={onSelect}
-        onDeleted={handleDeleted}
-      />
+      {disabled ? (
+        <InfoBanner
+          data-testid="ai-mock-history-disabled"
+          description={t('ai.mockConversationsUnavailable')}
+        />
+      ) : (
+        <ConversationListPanel
+          activeConversationId={activeConversationId}
+          onSelect={onSelect}
+          onDeleted={handleDeleted}
+        />
+      )}
     </Modal>
   );
 };
diff --git a/web/src/pages/ai/hooks/useDraftHandoff.ts 
b/web/src/pages/ai/hooks/useDraftHandoff.ts
index 50f3991f8..3787f91b1 100644
--- a/web/src/pages/ai/hooks/useDraftHandoff.ts
+++ b/web/src/pages/ai/hooks/useDraftHandoff.ts
@@ -41,6 +41,8 @@ import type { StartRunOptions } from './useAiSend';
  */
 
 export interface UseDraftHandoffOptions {
+  /** False consumes drafts locally without creating a conversation or sending 
to the backend. */
+  sendEnabled?: boolean;
   /** Write the draft into the composer state (text, model, engine, mode, 
enhance). */
   applyDraft: (draft: ChatDraft) => void;
   /** The message request the draft auto-sends once its conversation is 
resolved. */
@@ -92,13 +94,20 @@ export function useDraftHandoff(
     consumedDraftRef.current = true;
 
     void (async () => {
-      const { applyDraft, buildDraftRequest, openHistory, startRun } = 
optionsRef.current;
+      const { applyDraft, buildDraftRequest, openHistory, sendEnabled, 
startRun } =
+        optionsRef.current;
       if (wantsHistory) openHistory();
       if (!draft) {
         navigate(location.pathname, { replace: true, state: null });
         return;
       }
       applyDraft(draft);
+      if (sendEnabled === false) {
+        // Mock handoffs still open the composer, but must neither mutate real 
conversations nor
+        // clear the unsent draft. Strip the intent so switching to Real 
cannot auto-submit it.
+        navigate(location.pathname, { replace: true, state: null });
+        return;
+      }
       const start: StartRunOptions = {
         createBody: { instanceId: draft.instanceId ?? null, mode: draft.mode 
?? 'chat' },
         request: buildDraftRequest(draft),
diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx
index be20c485e..bdfd2831f 100644
--- a/web/src/pages/ai/index.tsx
+++ b/web/src/pages/ai/index.tsx
@@ -62,8 +62,10 @@ const AiPage = () => {
   const { t } = useLang();
   const { token } = theme.useToken();
   const navigate = useNavigate();
-  const conversationId = parseConversationId(useParams().conversationId);
+  const routeConversationId = parseConversationId(useParams().conversationId);
   const useMock = useDataModeStore((state) => state.useMock);
+  // Persisted conversations and active runs are real backend resources, even 
on a Mock deep link.
+  const conversationId = useMock ? null : routeConversationId;
   const userId = useAuthStore((state) => state.userId);
   const admin = useAuthStore((state) => state.admin);
   const engine = useEngineStore((state) => state.engine);
@@ -101,6 +103,7 @@ const AiPage = () => {
   });
 
   useDraftHandoff(conversationId, {
+    sendEnabled: !useMock,
     applyDraft: (draft) => {
       setInputValue(draft.prompt);
       if (draft.enhance !== undefined) setEnhance(draft.enhance);
@@ -344,6 +347,7 @@ const AiPage = () => {
       />
       <ConversationListModal
         open={overlay === 'history'}
+        disabled={useMock}
         onClose={() => setOverlay(null)}
         activeConversationId={conversationId}
         onSelect={(id) => {

Reply via email to