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 830bf433f fix(ai): preserve newer drafts after conversation creation 
fails (#6417)
830bf433f is described below

commit 830bf433f58bec57d10dd042978d91bb039f1581
Author: bai mengxuan <[email protected]>
AuthorDate: Thu Oct 15 18:16:59 2026 +0800

    fix(ai): preserve newer drafts after conversation creation fails (#6417)
---
 web/src/pages/ai/__tests__/AiPage.test.tsx | 42 ++++++++++++++++++++++++++++++
 web/src/pages/ai/components/Composer.tsx   |  4 +--
 web/src/pages/ai/hooks/useComposerDraft.ts | 24 ++++++++++++++---
 web/src/pages/ai/index.tsx                 | 17 +++++++++---
 4 files changed, 79 insertions(+), 8 deletions(-)

diff --git a/web/src/pages/ai/__tests__/AiPage.test.tsx 
b/web/src/pages/ai/__tests__/AiPage.test.tsx
index 90949dc71..5f205ae6e 100644
--- a/web/src/pages/ai/__tests__/AiPage.test.tsx
+++ b/web/src/pages/ai/__tests__/AiPage.test.tsx
@@ -42,6 +42,7 @@ import { listInstances } from 
'../../../services/instanceService';
 import useAuthStore from '../../../stores/authStore';
 import { useEngineStore } from '../../../stores/engineStore';
 import AiPage from '../index';
+import { COMPOSER_DRAFT_STORAGE_KEY } from '../hooks/useComposerDraft';
 
 /**
  * The page shell, end to end: route param in, hooks wired, `ChatThread` + 
`Composer` out.
@@ -400,6 +401,47 @@ describe('AiPage', () => {
     expect(attachRunStream).toHaveBeenCalledTimes(1);
   });
 
+  it.each(['unchanged', 'typed', 'cleared', 'template'])(
+    'preservesNewerDraftAfterConversationCreationFailsTest: %s',
+    async (edit) => {
+      let rejectCreation!: (error: Error) => void;
+      vi.mocked(createConversation).mockReturnValueOnce(
+        new Promise<AiConversationVO>((_resolve, reject) => {
+          rejectCreation = reject;
+        }),
+      );
+      const user = userEvent.setup({ pointerEventsCheck: 0 });
+      renderPage();
+      const input = await typeAndWaitForReady('original question');
+      await user.click(screen.getByTestId('ai-send-stop-button'));
+      await waitFor(() => expect(createConversation).toHaveBeenCalledTimes(1));
+      expect(input).toHaveValue('');
+
+      let expected = 'original question';
+      if (edit === 'typed' || edit === 'cleared') {
+        await user.type(input, 'new unsent draft');
+        expected = 'new unsent draft';
+        if (edit === 'cleared') {
+          await user.clear(input);
+          expected = '';
+        }
+      } else if (edit === 'template') {
+        await user.click(screen.getByRole('button', { name: '模板' }));
+        const dialog = await screen.findByRole('dialog', { name: 'Prompt 模板' 
});
+        await user.click(within(dialog).getAllByRole('button', { name: /使\s*用/ 
})[0]);
+        expected = (input as HTMLTextAreaElement).value;
+        expect(expected).toContain('请诊断当前 RocketMQ 实例中的消费延迟问题');
+      }
+
+      await act(async () => rejectCreation(new Error('conversation creation 
failed')));
+      expect(await screen.findByText('conversation creation 
failed')).toBeInTheDocument();
+      expect(input).toHaveValue(expected);
+      expect(sessionStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY)).toBe(expected 
|| null);
+      expect(readProbeLocation().pathname).toBe('/ai');
+      expect(openRunStream).not.toHaveBeenCalled();
+    },
+  );
+
   it('surfacesTheServerRejectionOfADoubleSendTest', async () => {
     vi.mocked(openRunStream).mockRejectedValue(
       Object.assign(new Error('该会话已有正在进行的回答'), {
diff --git a/web/src/pages/ai/components/Composer.tsx 
b/web/src/pages/ai/components/Composer.tsx
index 5b4dd65db..c9ae54cac 100644
--- a/web/src/pages/ai/components/Composer.tsx
+++ b/web/src/pages/ai/components/Composer.tsx
@@ -107,7 +107,7 @@ export interface ComposerProps {
   value: string;
   onChange: (value: string) => void;
   /**
-   * Called with the trimmed draft once a send was accepted. The composer 
clears the draft itself, so
+   * Called with the trimmed draft after the composer clears it, so
    * a caller that has to reject the send (no conversation yet, 409 from the 
server) should put the
    * text back with `onChange`.
    */
@@ -260,8 +260,8 @@ const Composer = ({
   const handleSend = useCallback(() => {
     const text = value.trim();
     if (!text || !canSend) return;
-    onSend(text);
     onChange('');
+    onSend(text);
   }, [canSend, onChange, onSend, value]);
 
   const handleKeyDown = useCallback(
diff --git a/web/src/pages/ai/hooks/useComposerDraft.ts 
b/web/src/pages/ai/hooks/useComposerDraft.ts
index 83c380ffb..29bad4630 100644
--- a/web/src/pages/ai/hooks/useComposerDraft.ts
+++ b/web/src/pages/ai/hooks/useComposerDraft.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { useCallback, useState } from 'react';
+import { useCallback, useRef, useState } from 'react';
 
 /**
  * The composer draft: the ONE piece of AI-page state that stays on the client.
@@ -60,16 +60,34 @@ function writeDraft(value: string): void {
   }
 }
 
-export type ComposerDraft = [value: string, setValue: (value: string) => void];
+export type ComposerDraft = [
+  value: string,
+  setValue: (value: string) => void,
+  captureRestore: (value: string) => () => void,
+];
 
 /** Draft text plus its persistence. `setValue('')` clears both the state and 
the storage key. */
 export function useComposerDraft(): ComposerDraft {
   const [value, setValueState] = useState<string>(readDraft);
+  const revision = useRef(0);
 
   const setValue = useCallback((next: string) => {
+    revision.current += 1;
     setValueState(next);
     writeDraft(next);
   }, []);
 
-  return [value, setValue];
+  // Capture after the submitted draft is cleared. Even editing back to an 
empty
+  // value owns a newer draft and must not be overwritten by a failed old send.
+  const captureRestore = useCallback(
+    (submitted: string) => {
+      const submittedRevision = revision.current;
+      return () => {
+        if (revision.current === submittedRevision) setValue(submitted);
+      };
+    },
+    [setValue],
+  );
+
+  return [value, setValue, captureRestore];
 }
diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx
index 4c3030e7a..ccf846351 100644
--- a/web/src/pages/ai/index.tsx
+++ b/web/src/pages/ai/index.tsx
@@ -67,7 +67,7 @@ const AiPage = () => {
   const admin = useAuthStore((state) => state.admin);
   const engine = useEngineStore((state) => state.engine);
   const setEngine = useEngineStore((state) => state.setEngine);
-  const [inputValue, setInputValue] = useComposerDraft();
+  const [inputValue, setInputValue, captureDraftRestore] = useComposerDraft();
   const [selectedMode, setSelectedMode] = useState<ChatMode>('chat');
   const [enhance, setEnhance] = useState(false);
   const [overlay, setOverlay] = useState<Overlay>(null);
@@ -116,14 +116,25 @@ const AiPage = () => {
         message.warning(t('ai.providerRequired'));
         return;
       }
+      const restoreDraft = captureDraftRestore(text);
       void startRun({
         createBody: { mode: selectedMode },
         request: buildMessageRequest(text, llm.selectedModel, engine, 
selectedMode, enhance),
       }).then((target) => {
-        if (target === null) setInputValue(text);
+        if (target === null) restoreDraft();
       });
     },
-    [engine, enhance, llm.llmReady, llm.selectedModel, selectedMode, 
setInputValue, startRun, t],
+    [
+      captureDraftRestore,
+      engine,
+      enhance,
+      llm.llmReady,
+      llm.selectedModel,
+      selectedMode,
+      setInputValue,
+      startRun,
+      t,
+    ],
   );
 
   // Estimated context footprint of this conversation — persisted text plus 
whatever the run in

Reply via email to