From 7c42ab3e1670235de2f317521f0b13ff16b0a104 Mon Sep 17 00:00:00 2001 From: Admire <64821731+LittleChenLiya@users.noreply.github.com> Date: Fri, 15 May 2026 22:27:10 +0800 Subject: [PATCH] fix(frontend): wait for async chat submit before clearing (#2940) * fix(frontend): wait for async chat submit before clearing * test(frontend): cover pending attachment uploads * fix(frontend): preserve sync submit semantics --- .../[agent_name]/chats/[thread_id]/page.tsx | 12 +++- .../app/workspace/chats/[thread_id]/page.tsx | 6 +- .../components/ai-elements/prompt-input.tsx | 32 +++++++--- .../src/components/workspace/input-box.tsx | 25 ++++++-- frontend/tests/e2e/chat.spec.ts | 62 +++++++++++++++++++ 5 files changed, 120 insertions(+), 17 deletions(-) diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index 8627762b0..c16af882a 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -66,6 +66,7 @@ export default function AgentChatPage() { thread, pendingUsageMessages, sendMessage, + isUploading, isHistoryLoading, hasMoreHistory, loadMoreHistory, @@ -106,7 +107,11 @@ export default function AgentChatPage() { const handleSubmit = useCallback( (message: PromptInputMessage) => { - void sendMessage(threadId, message, { agent_name }); + const sendPromise = sendMessage(threadId, message, { agent_name }); + if (message.files.length > 0) { + return sendPromise; + } + void sendPromise; }, [sendMessage, threadId, agent_name], ); @@ -243,7 +248,10 @@ export default function AgentChatPage() { ) } - disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"} + disabled={ + env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || + isUploading + } onContextChange={(context) => setSettings("context", context)} onSubmit={handleSubmit} onStop={handleStop} diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index ed7d91c68..6f865ade8 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -109,7 +109,11 @@ export default function ChatPage() { const handleSubmit = useCallback( (message: PromptInputMessage) => { - void sendMessage(threadId, message); + const sendPromise = sendMessage(threadId, message); + if (message.files.length > 0) { + return sendPromise; + } + void sendPromise; }, [sendMessage, threadId], ); diff --git a/frontend/src/components/ai-elements/prompt-input.tsx b/frontend/src/components/ai-elements/prompt-input.tsx index 52a909cdd..4609c43d3 100644 --- a/frontend/src/components/ai-elements/prompt-input.tsx +++ b/frontend/src/components/ai-elements/prompt-input.tsx @@ -499,6 +499,10 @@ export const PromptInput = ({ // Keep a ref to files for cleanup on unmount (avoids stale closure) const filesRef = useRef(files); filesRef.current = files; + const providerTextRef = useRef(""); + if (usingProvider) { + providerTextRef.current = controller.textInput.value; + } const openFileDialogLocal = useCallback(() => { inputRef.current?.click(); @@ -768,6 +772,24 @@ export const PromptInput = ({ } // Convert blob URLs to data URLs asynchronously + const submittedFileIds = files.map((file) => file.id); + const clearSubmittedState = () => { + const currentFileIds = new Set(filesRef.current.map((file) => file.id)); + const submittedFileIdsStillPresent = submittedFileIds.filter((id) => + currentFileIds.has(id), + ); + if (submittedFileIdsStillPresent.length === filesRef.current.length) { + clear(); + } else { + for (const id of submittedFileIdsStillPresent) { + remove(id); + } + } + if (usingProvider && providerTextRef.current === text) { + controller.textInput.clear(); + } + }; + Promise.all( files.map(async ({ id, ...item }) => { if (item.file instanceof File) { @@ -793,20 +815,14 @@ export const PromptInput = ({ if (result instanceof Promise) { result .then(() => { - clear(); - if (usingProvider) { - controller.textInput.clear(); - } + clearSubmittedState(); }) .catch(() => { // Don't clear on error - user may want to retry }); } else { // Sync function completed without throwing, clear attachments - clear(); - if (usingProvider) { - controller.textInput.clear(); - } + clearSubmittedState(); } } catch { // Don't clear on error - user may want to retry diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 9a33d41e6..6344a26d2 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -110,6 +110,7 @@ export function InputBox({ threadId, initialValue, onContextChange, + onFollowupsVisibilityChange, onSubmit, onStop, ...props @@ -142,7 +143,8 @@ export function InputBox({ reasoning_effort?: "minimal" | "low" | "medium" | "high"; }, ) => void; - onSubmit?: (message: PromptInputMessage) => void; + onFollowupsVisibilityChange?: (visible: boolean) => void; + onSubmit?: (message: PromptInputMessage) => void | Promise; onStop?: () => void; }) { const { t } = useI18n(); @@ -251,12 +253,12 @@ export function InputBox({ ); const handleSubmit = useCallback( - async (message: PromptInputMessage) => { + (message: PromptInputMessage) => { if (status === "streaming") { onStop?.(); return; } - if (!message.text) { + if (!message.text.trim() && message.files.length === 0) { return; } setFollowups([]); @@ -274,11 +276,14 @@ export function InputBox({ selectedModel?.supports_thinking ?? false, ), }); - setTimeout(() => onSubmit?.(message), 0); - return; + return new Promise((resolve, reject) => { + setTimeout(() => { + Promise.resolve(onSubmit?.(message)).then(resolve).catch(reject); + }, 0); + }); } - onSubmit?.(message); + return onSubmit?.(message); }, [ context, @@ -348,6 +353,14 @@ export function InputBox({ !followupsHidden && (followupsLoading || followups.length > 0); + useEffect(() => { + onFollowupsVisibilityChange?.(showFollowups); + }, [onFollowupsVisibilityChange, showFollowups]); + + useEffect(() => { + return () => onFollowupsVisibilityChange?.(false); + }, [onFollowupsVisibilityChange]); + useEffect(() => { messagesRef.current = thread.messages; }, [thread.messages]); diff --git a/frontend/tests/e2e/chat.spec.ts b/frontend/tests/e2e/chat.spec.ts index 490305de9..e608793df 100644 --- a/frontend/tests/e2e/chat.spec.ts +++ b/frontend/tests/e2e/chat.spec.ts @@ -48,4 +48,66 @@ test.describe("Chat workspace", () => { timeout: 10_000, }); }); + + test("keeps attachments visible while upload submit is pending", async ({ + page, + }) => { + let releaseUpload!: () => void; + const uploadCanFinish = new Promise((resolve) => { + releaseUpload = resolve; + }); + let uploadStarted!: () => void; + const uploadStartedPromise = new Promise((resolve) => { + uploadStarted = resolve; + }); + + await page.route("**/api/threads/*/uploads", async (route) => { + uploadStarted(); + await uploadCanFinish; + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + success: true, + message: "Uploaded", + files: [ + { + filename: "report.docx", + size: 12, + path: "report.docx", + virtual_path: "/mnt/user-data/uploads/report.docx", + artifact_url: "/api/threads/test/uploads/report.docx", + extension: ".docx", + }, + ], + }), + }); + }); + + await page.goto("/workspace/chats/new"); + + const textarea = page.getByPlaceholder(/how can i assist you/i); + await expect(textarea).toBeVisible({ timeout: 15_000 }); + const promptForm = page.locator("form").filter({ has: textarea }); + + await page.getByLabel("Upload files").setInputFiles({ + name: "report.docx", + mimeType: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + buffer: Buffer.from("fake docx"), + }); + await expect(promptForm.getByText("report.docx")).toBeVisible(); + + await textarea.fill("Summarize this document"); + await textarea.press("Enter"); + + await uploadStartedPromise; + await expect(promptForm.getByText("report.docx")).toBeVisible(); + + releaseUpload(); + await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({ + timeout: 10_000, + }); + await expect(promptForm.getByText("report.docx")).toBeHidden(); + }); });