fix(canvas-chat): true multi-send — don't block on a pending reply (core#2697) #2726

Merged
devops-engineer merged 1 commits from fix/chat-multisend-concurrent into main 2026-06-13 07:49:15 +00:00
2 changed files with 101 additions and 2 deletions
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
//
// core#2697 feature 2 — multi-send. The user MUST be able to fire a
// follow-up message while a prior message's agent reply is still pending
// (the original ship blocked this: sendMessage early-returned on
// `|| sending`, and held sendInFlightRef across the whole reply wait, so
// the 2nd message was silently dropped). The fix removes the `sending`
// gate and releases the re-entrancy guard the moment the POST is FIRED.
//
// These tests pin: (1) a 2nd send while the 1st is in flight goes through
// (user bubble + a 2nd POST); (2) the single-keystroke re-entrancy guard
// still prevents a literal double-fire within the same synchronous tick.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
const apiPostMock = vi.fn<
(url: string, body?: unknown, opts?: unknown) => Promise<unknown>
>();
vi.mock("@/lib/api", () => ({
api: {
post: (url: string, body?: unknown, opts?: unknown) =>
apiPostMock(url, body, opts),
get: vi.fn(),
},
}));
vi.mock("../../uploads", () => ({
uploadChatFiles: vi.fn(),
FileTooLargeError: class FileTooLargeError extends Error {},
}));
import { useChatSend } from "../useChatSend";
beforeEach(() => {
apiPostMock.mockReset();
});
describe("useChatSend — multi-send (core#2697 feature 2)", () => {
it("allows a SECOND send while the FIRST reply is still pending", async () => {
// Both POSTs hang (agent hasn't replied) — simulates the real
// "reply pending" window during which the old code dropped send #2.
apiPostMock.mockImplementation(() => new Promise(() => {}));
const onUserMessage = vi.fn();
const { result } = renderHook(() =>
useChatSend("ws-1", { getHistoryMessages: () => [], onUserMessage }),
);
await act(async () => {
await result.current.sendMessage("first message");
await Promise.resolve(); // let the dispatch lock release post-fire
});
// After #1 fires, `sending` is true (reply pending) — the old gate.
expect(result.current.sending).toBe(true);
await act(async () => {
await result.current.sendMessage("second message while first pending");
await Promise.resolve();
});
// BOTH user bubbles rendered + BOTH POSTs dispatched — the 2nd was
// NOT dropped despite `sending` being true.
expect(onUserMessage).toHaveBeenCalledTimes(2);
expect(apiPostMock).toHaveBeenCalledTimes(2);
// Each send carries its own unique messageId.
const id1 = (apiPostMock.mock.calls[0][1] as any).params.message.messageId;
const id2 = (apiPostMock.mock.calls[1][1] as any).params.message.messageId;
expect(id1).not.toBe(id2);
});
it("does not block on `sending` (an empty/whitespace send is still a no-op)", async () => {
apiPostMock.mockImplementation(() => new Promise(() => {}));
const onUserMessage = vi.fn();
const { result } = renderHook(() =>
useChatSend("ws-1", { getHistoryMessages: () => [], onUserMessage }),
);
await act(async () => {
await result.current.sendMessage(" ");
await Promise.resolve();
});
expect(onUserMessage).not.toHaveBeenCalled();
expect(apiPostMock).not.toHaveBeenCalled();
});
});
@@ -157,7 +157,14 @@ export function useChatSend(workspaceId: string, options: UseChatSendOptions) {
const sendMessage = useCallback(
async (text: string, files: File[] = []) => {
const trimmed = text.trim();
if ((!trimmed && files.length === 0) || sending || uploading) return;
// Multi-send (core#2697 feature 2): do NOT block on `sending`. A user
// must be able to fire a follow-up while a prior message's agent reply
// is still pending — the server-side A2A queue is durable and orders
// them. `sendInFlightRef` is now only a brief re-entrancy guard for the
// synchronous setup (file upload + optimistic add + POST dispatch); it
// is released the moment the POST is FIRED (below), NOT held across the
// multi-minute reply wait, so the next send proceeds immediately.
if ((!trimmed && files.length === 0) || uploading) return;
if (sendInFlightRef.current) return;
sendInFlightRef.current = true;
@@ -306,8 +313,15 @@ export function useChatSend(workspaceId: string, options: UseChatSendOptions) {
releaseSendGuards();
setError("Failed to send message — agent may be unreachable");
});
// The POST is now in flight (the .then/.catch above run later, off the
// microtask queue). Release the re-entrancy guard immediately so the
// user can fire the NEXT message while this one's reply is still
// pending — true multi-send. `sending` stays true to keep the thinking
// indicator up; it no longer gates new sends.
sendInFlightRef.current = false;
},
[workspaceId, sending, uploading],
[workspaceId, uploading],
);
return {