diff --git a/packages/app/src/composer/draft/create-flow.test.ts b/packages/app/src/composer/draft/create-flow.test.ts index 471d1fc8b9..a41e4558b3 100644 --- a/packages/app/src/composer/draft/create-flow.test.ts +++ b/packages/app/src/composer/draft/create-flow.test.ts @@ -88,6 +88,43 @@ describe("useDraftAgentCreateFlow", () => { expect(onCreateSuccess).toHaveBeenCalledTimes(1); }); + it("starts one create request when submit is invoked twice before React rerenders", async () => { + let resolveCreate: ((value: { agentId: string; result: { id: string } }) => void) | undefined; + const createRequest = vi.fn( + () => + new Promise<{ agentId: string; result: { id: string } }>((resolve) => { + resolveCreate = resolve; + }), + ); + const onCreateSuccess = vi.fn(); + const { result } = renderHook(() => + useDraftAgentCreateFlow({ + draftId: "draft-1", + getPendingServerId: () => "server-1", + buildDraftAgent: (attempt) => ({ attempt }), + createRequest, + onCreateSuccess, + }), + ); + const submission = { text: "build this", attachments: [], cwd: "/repo" }; + + let first: Promise; + let duplicate: Promise; + act(() => { + first = result.current.handleCreateFromInput(submission); + duplicate = result.current.handleCreateFromInput(submission); + }); + + await expect(duplicate!).rejects.toThrow(); + expect(createRequest).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveCreate?.({ agentId: "agent-1", result: { id: "agent-1" } }); + await first!; + }); + expect(onCreateSuccess).toHaveBeenCalledTimes(1); + }); + it("allows retrying an empty prompt when the draft still has context attachments", async () => { const attachment = { kind: "chat_history", diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index 89a237e786..4955234872 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -84,6 +84,17 @@ interface CreateRequestContext { cwd: string; } +function assertCreateAvailable(input: { + draftId: string; + isSubmitting: boolean; + alreadyLoadingMessage: string; +}): void { + const pending = useCreateFlowStore.getState().pendingByDraftId[input.draftId]; + if (input.isSubmitting || pending?.lifecycle === "active") { + throw new Error(input.alreadyLoadingMessage); + } +} + interface UseDraftAgentCreateFlowOptions { draftId: string; getPendingServerId: () => string | null; @@ -239,9 +250,11 @@ export function useDraftAgentCreateFlow({ const handleCreateFromInput = useCallback( async ({ text, attachments, cwd }: SubmitContext) => { - if (isSubmitting) { - throw new Error(t("composer.errors.alreadyLoading")); - } + assertCreateAvailable({ + draftId, + isSubmitting, + alreadyLoadingMessage: t("composer.errors.alreadyLoading"), + }); dispatch({ type: "DRAFT_SET_ERROR", message: "" }); const trimmedPrompt = text.trim();