deer-flow/frontend/tests/unit/components/workspace/input-box-project-attachment.dom.test.tsx
Hyeonsang Cho f0476677de
fix(frontend): keep composer context when an attachment upload fails (#6412)
* fix(frontend): keep composer context when an attachment upload fails

sendMessage called onSent right after the in-flight guard, before any
attachment was prepared or uploaded. The composer's onSent drops its
one-time state there: quotes, conversation references, staged project
files and the stored draft. PromptInput keeps the text and files when the
upload rejects, so the user saw the same message and retried it, but the
retry went out without that context.

onSent now fires once the send is dispatched: after the attachments
upload, right before the run is submitted. A failed preparation or upload
rejects first, so every caller (the chat composer and the side chat)
keeps its state for a retry.

Because onSent can now arrive after the composer has moved to another
conversation, InputBox checks which conversation it shows. If it changed,
onSent leaves that conversation's live state alone and retires only the
sending conversation's stored draft and staged files, through a new
clearProjectAttachments helper that does not touch mounted state.

The chat E2E that held an attachment upload asserted the draft was gone
mid-upload, which encoded the old timing. It now asserts the draft stays
stored while the upload is pending and is gone, without being rewritten,
once the send is dispatched.

* docs(changelog): reference #6412

* docs(frontend): note onSent's guarantee ends at dispatch

* fix(frontend): retire only what a detached send carried

A send whose upload finishes after its composer unmounted still took the
live path, because only a conversation switch was detected, and could
delete a draft a new composer saved under the same reusable key. A late
send also cleared the thread's whole staged list, dropping a document
attached during the upload.

Track the composer's lifetime instead of its key, and in the detached
path clear the stored draft only while it still holds the sent text and
retire only the staged entries the send captured.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-07 17:55:15 +08:00

421 lines
14 KiB
TypeScript

import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import type { ReactNode } from "react";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import {
InputBox,
type InputBoxSubmitOptions,
} from "@/components/workspace/input-box";
import { ThreadContext } from "@/components/workspace/messages/context";
import { AuthProvider } from "@/core/auth/AuthProvider";
import { DEFAULT_LOCALE } from "@/core/i18n";
import { I18nProvider } from "@/core/i18n/context";
import {
readProjectAttachments,
stageProjectAttachment,
} from "@/core/projects/composer-attach";
import type { AttachProjectDocumentResult } from "@/core/projects/types";
import {
buildComposerDraftKey,
getSessionComposerDraftStorage,
readComposerDraft,
writeComposerDraft,
} from "@/core/threads/composer-draft";
rs.mock("next/navigation", () => ({
useRouter: () => ({ push: rs.fn(), replace: rs.fn(), refresh: rs.fn() }),
usePathname: () => "/workspace",
useSearchParams: () => new URLSearchParams(),
}));
// The composer's model selector is irrelevant to submit gating; keep the
// react-query + network machinery out of the way entirely.
rs.mock("@/core/models/hooks", () => ({
useModels: () => ({
models: [],
tokenUsageEnabled: false,
isLoading: false,
isFetching: false,
error: null,
refetch: rs.fn(),
}),
}));
const ATTACHMENT: AttachProjectDocumentResult = {
filename: "roadmap.md",
size_bytes: 2048,
virtual_path: "/mnt/user-data/uploads/roadmap.md",
artifact_url:
"/api/threads/thread-1/artifacts/mnt/user-data/uploads/roadmap.md",
};
const STAGED_FILE = {
filename: "roadmap.md",
size: 2048,
path: "/mnt/user-data/uploads/roadmap.md",
status: "uploaded",
};
const OTHER_ATTACHMENT: AttachProjectDocumentResult = {
filename: "notes.txt",
size_bytes: 128,
virtual_path: "/mnt/user-data/uploads/notes.txt",
artifact_url:
"/api/threads/thread-1/artifacts/mnt/user-data/uploads/notes.txt",
};
const OTHER_STAGED_FILE = {
filename: "notes.txt",
size: 128,
path: "/mnt/user-data/uploads/notes.txt",
status: "uploaded",
};
type SubmitSpy = ReturnType<typeof rs.fn>;
function renderComposer({ onSubmit }: { onSubmit: SubmitSpy }) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const tree = (onSubmitProp: SubmitSpy, threadId = "thread-1"): ReactNode => (
<I18nProvider initialLocale={DEFAULT_LOCALE}>
<QueryClientProvider client={queryClient}>
<AuthProvider
initialUser={{
id: "user-1",
email: "user@example.test",
system_role: "user",
needs_setup: false,
oauth_provider: null,
}}
>
<ThreadContext.Provider
value={{ thread: { messages: [] } as never, isMock: true }}
>
<PromptInputProvider>
<InputBox
threadId={threadId}
status="ready"
context={{ mode: "flash" } as never}
onSubmit={onSubmitProp}
/>
</PromptInputProvider>
</ThreadContext.Provider>
</AuthProvider>
</QueryClientProvider>
</I18nProvider>
);
const rendered = render(tree(onSubmit));
return {
...rendered,
switchThread: (threadId: string) =>
rendered.rerender(tree(onSubmit, threadId)),
};
}
function getSubmitButton(container: HTMLElement): HTMLButtonElement {
const button = container.querySelector('button[type="submit"]');
if (!(button instanceof HTMLButtonElement)) {
throw new Error("submit button not rendered");
}
return button;
}
function lastSubmit(onSubmit: SubmitSpy): {
message: PromptInputMessage;
options: InputBoxSubmitOptions | undefined;
} {
const [message, options] = onSubmit.mock.calls.at(-1) as [
PromptInputMessage,
InputBoxSubmitOptions?,
];
return { message, options };
}
afterEach(() => {
rs.restoreAllMocks();
cleanup();
window.sessionStorage.clear();
});
describe("InputBox staged project attachments", () => {
it("submits an attachment-only message instead of reading it as empty", async () => {
stageProjectAttachment("thread-1", ATTACHMENT);
const onSubmit = rs.fn();
const { container } = renderComposer({ onSubmit });
// The staged chip is applied from session storage on mount.
await screen.findByTestId("project-attachment-chip");
fireEvent.click(getSubmitButton(container));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const { message, options } = lastSubmit(onSubmit);
expect(message.text).toBe("");
expect(options?.additionalKwargs?.files).toEqual([STAGED_FILE]);
});
it("does not intercept /goal while an attach chip is present", async () => {
stageProjectAttachment("thread-1", ATTACHMENT);
const onSubmit = rs.fn();
const { container } = renderComposer({ onSubmit });
await screen.findByTestId("project-attachment-chip");
fireEvent.change(screen.getByRole("textbox"), {
target: { value: "/goal check this" },
});
fireEvent.click(getSubmitButton(container));
// The line goes out as an ordinary message WITH the attachment — the
// goal interception would have stripped the "/goal " prefix and dropped
// the staged files.
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const { message, options } = lastSubmit(onSubmit);
expect(message.text).toBe("/goal check this");
expect(options?.additionalKwargs?.files).toEqual([STAGED_FILE]);
});
it("keeps plain text-only and truly-empty submit behavior unchanged", async () => {
const onSubmit = rs.fn();
const { container } = renderComposer({ onSubmit });
// No staged attachment, empty text: nothing is submitted.
fireEvent.click(getSubmitButton(container));
await waitFor(() =>
expect(screen.queryByTestId("project-attachment-chip")).toBeNull(),
);
expect(onSubmit).not.toHaveBeenCalled();
// Plain text submits with no staged files attached.
fireEvent.change(screen.getByRole("textbox"), {
target: { value: "hello" },
});
fireEvent.click(getSubmitButton(container));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const { message, options } = lastSubmit(onSubmit);
expect(message.text).toBe("hello");
expect(options?.additionalKwargs).toBeUndefined();
});
it("submits every pending shelf attachment when several were staged across a navigation", async () => {
// Attaching the first document and navigating away unmounts the
// composer; the project page appends the second one to the same pending
// list. Both must reach the next message.
stageProjectAttachment("thread-1", ATTACHMENT);
stageProjectAttachment("thread-1", OTHER_ATTACHMENT);
const onSubmit = rs.fn();
const { container } = renderComposer({ onSubmit });
await waitFor(() =>
expect(screen.getAllByTestId("project-attachment-chip")).toHaveLength(2),
);
fireEvent.click(getSubmitButton(container));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const { options } = lastSubmit(onSubmit);
expect(options?.additionalKwargs?.files).toEqual([
STAGED_FILE,
OTHER_STAGED_FILE,
]);
});
it("keeps a removed chip removed across a remount", async () => {
stageProjectAttachment("thread-1", ATTACHMENT);
stageProjectAttachment("thread-1", OTHER_ATTACHMENT);
const onSubmit = rs.fn();
const first = renderComposer({ onSubmit });
await waitFor(() =>
expect(screen.getAllByTestId("project-attachment-chip")).toHaveLength(2),
);
const [firstRemoveButton] = screen.getAllByLabelText(
"Remove attached document",
);
if (!firstRemoveButton) {
throw new Error("remove button not rendered");
}
fireEvent.click(firstRemoveButton);
await waitFor(() =>
expect(screen.getAllByTestId("project-attachment-chip")).toHaveLength(1),
);
first.unmount();
const second = renderComposer({ onSubmit });
await waitFor(() =>
expect(screen.getAllByTestId("project-attachment-chip")).toHaveLength(1),
);
expect(screen.getByText(OTHER_ATTACHMENT.filename)).toBeTruthy();
second.unmount();
});
it("resets the staged count after a sent message clears the chip", async () => {
stageProjectAttachment("thread-1", ATTACHMENT);
const onSubmit: SubmitSpy = rs.fn(
(_message: unknown, options?: InputBoxSubmitOptions) => {
options?.onSent?.();
},
);
const { container } = renderComposer({ onSubmit });
await screen.findByTestId("project-attachment-chip");
fireEvent.click(getSubmitButton(container));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
// onSent cleared the staged attachments; a second empty submit is a
// no-op again.
await waitFor(() =>
expect(screen.queryByTestId("project-attachment-chip")).toBeNull(),
);
fireEvent.click(getSubmitButton(container));
await waitFor(() =>
expect(screen.queryByTestId("project-attachment-chip")).toBeNull(),
);
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it("retires only the sending thread's state when its upload finishes after a thread switch", async () => {
stageProjectAttachment("thread-1", ATTACHMENT);
stageProjectAttachment("thread-2", OTHER_ATTACHMENT);
let dispatched!: () => void;
const onSubmit: SubmitSpy = rs.fn(
(_message: unknown, options?: InputBoxSubmitOptions) => {
// Keep the send open, as an attachment upload does, and report it
// dispatched only later.
dispatched = () => options?.onSent?.();
return new Promise<void>(() => undefined);
},
);
const { container, switchThread } = renderComposer({ onSubmit });
await screen.findByTestId("project-attachment-chip");
fireEvent.change(screen.getByRole("textbox"), {
target: { value: "summarize it" },
});
await new Promise((resolve) => setTimeout(resolve, 400));
const sendingDraftKey = buildComposerDraftKey({
userId: "user-1",
threadId: "thread-1",
});
const storage = window.sessionStorage;
expect(readComposerDraft(storage, sendingDraftKey)?.text).toBe(
"summarize it",
);
fireEvent.click(getSubmitButton(container));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
switchThread("thread-2");
await screen.findByText(OTHER_ATTACHMENT.filename);
dispatched();
// The composer now shows thread-2: its staged chip survives, while the
// sent thread's persisted draft and staged attachment are retired.
await new Promise((resolve) => setTimeout(resolve, 50));
expect(screen.getAllByTestId("project-attachment-chip")).toHaveLength(1);
expect(screen.getByText(OTHER_ATTACHMENT.filename)).toBeTruthy();
expect(readProjectAttachments("thread-2")).toEqual([OTHER_ATTACHMENT]);
expect(readProjectAttachments("thread-1")).toEqual([]);
expect(readComposerDraft(storage, sendingDraftKey)).toBeNull();
});
it("keeps a replacement draft saved under the same key after an unmounted composer's send is dispatched", async () => {
let dispatched!: () => void;
const onSubmit: SubmitSpy = rs.fn(
(_message: unknown, options?: InputBoxSubmitOptions) => {
dispatched = () => options?.onSent?.();
return new Promise<void>(() => undefined);
},
);
const { container, unmount } = renderComposer({ onSubmit });
fireEvent.change(screen.getByRole("textbox"), {
target: { value: "first question" },
});
fireEvent.click(getSubmitButton(container));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
// Leaving mid-upload persists the pending draft so a failed send could
// be retried; a later composer for the same key then saves its own.
unmount();
const draftKey = buildComposerDraftKey({
userId: "user-1",
threadId: "thread-1",
});
const storage = getSessionComposerDraftStorage();
expect(readComposerDraft(storage, draftKey)?.text).toBe("first question");
writeComposerDraft(storage, draftKey, {
text: "a different question",
skillName: null,
});
dispatched();
expect(readComposerDraft(storage, draftKey)?.text).toBe(
"a different question",
);
});
it("retires an unmounted composer's sent draft once its send is dispatched", async () => {
let dispatched!: () => void;
const onSubmit: SubmitSpy = rs.fn(
(_message: unknown, options?: InputBoxSubmitOptions) => {
dispatched = () => options?.onSent?.();
return new Promise<void>(() => undefined);
},
);
const { container, unmount } = renderComposer({ onSubmit });
fireEvent.change(screen.getByRole("textbox"), {
target: { value: "first question" },
});
fireEvent.click(getSubmitButton(container));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
unmount();
const draftKey = buildComposerDraftKey({
userId: "user-1",
threadId: "thread-1",
});
const storage = getSessionComposerDraftStorage();
expect(readComposerDraft(storage, draftKey)?.text).toBe("first question");
dispatched();
expect(readComposerDraft(storage, draftKey)).toBeNull();
});
it.each(["switches threads", "unmounts"] as const)(
"keeps a document staged during the upload after the composer %s",
async (leave) => {
stageProjectAttachment("thread-1", ATTACHMENT);
let dispatched!: () => void;
const onSubmit: SubmitSpy = rs.fn(
(_message: unknown, options?: InputBoxSubmitOptions) => {
dispatched = () => options?.onSent?.();
return new Promise<void>(() => undefined);
},
);
const { container, switchThread, unmount } = renderComposer({
onSubmit,
});
await screen.findByTestId("project-attachment-chip");
fireEvent.click(getSubmitButton(container));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
if (leave === "unmounts") {
unmount();
} else {
switchThread("thread-2");
}
// Attached from the project page while the first send still uploads;
// that send cannot carry it, so the next message must.
stageProjectAttachment("thread-1", OTHER_ATTACHMENT);
dispatched();
expect(readProjectAttachments("thread-1")).toEqual([OTHER_ATTACHMENT]);
},
);
});