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
This commit is contained in:
Admire
2026-05-15 22:27:10 +08:00
committed by GitHub
parent 7a2670eaea
commit 7c42ab3e16
5 changed files with 120 additions and 17 deletions
@@ -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() {
<AgentWelcome agent={agent} agentName={agent_name} />
)
}
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}
@@ -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],
);
@@ -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
@@ -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<void>;
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<void>((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]);
+62
View File
@@ -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<void>((resolve) => {
releaseUpload = resolve;
});
let uploadStarted!: () => void;
const uploadStartedPromise = new Promise<void>((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();
});
});