Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 4d08ddba94 fix(app): prevent concurrent draft agent creation
React submit state is render-lagged, so callbacks entering in the same render can all start create requests. Claim the draft synchronously in the shared create-flow store before the first request begins.
2026-08-12 11:45:51 +00:00
2 changed files with 53 additions and 3 deletions
@@ -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<void>;
let duplicate: Promise<void>;
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",
+16 -3
View File
@@ -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<TDraftAgent, TCreateResult> {
draftId: string;
getPendingServerId: () => string | null;
@@ -239,9 +250,11 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
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();