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:
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user