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