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