Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions packages/chat-ui/src/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,13 @@ export const Composer = forwardRef<
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const attachGenerationRef = useRef(0);
// `sending` is React state, set only after `onSend` has already been
// awaited into — two calls to `performSend` inside one synchronous tick
// (an Enter keydown and a click both firing before a render lands) would
// both read `sending === false` and both post. This ref is set the
// instant a send starts, synchronously ahead of any render, so a second
// call in the same tick is turned away (CL-7198).
const sendInFlightRef = useRef(false);

/** Auto-grow: the textarea reports its own content height, so the
* measurement resets to the CSS-declared min-height before reading
Expand Down Expand Up @@ -455,10 +462,16 @@ export const Composer = forwardRef<
* recovering the text is the pending bubble's own Discard action.
*/
async function performSend(payload: ComposerSendPayload): Promise<void> {
if (sendInFlightRef.current) return;
sendInFlightRef.current = true;
setSending(true);
setErrorMessage(null);
await onSend(payload);
setSending(false);
try {
await onSend(payload);
} finally {
sendInFlightRef.current = false;
setSending(false);
}
}

function runSlashCommand(command: SlashCommandSpec) {
Expand Down
21 changes: 19 additions & 2 deletions packages/chat-ui/src/use-optimistic-sends.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
// either arrival shows up with a matching `clientId`, so whichever wins
// that race, the other is a no-op.

import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { ChatApiError, pingWorkbenchPresence, sendMessage } from "./api";
import type { MessageItem, MessagesResponse } from "./api";
Expand Down Expand Up @@ -172,6 +172,16 @@ export function useOptimisticSends(args: {
setPendingSends([]);
}, [activeWorkbenchId]);

// `sendPending` closes over the `activeWorkbenchId` its own render was
// called with, which stays fixed for the life of that async call. This
// ref tracks the live value so a continuation resolving after the reader
// has switched benches can tell its own send is no longer for the
// workbench currently on screen (CL-7198).
const activeWorkbenchIdRef = useRef(activeWorkbenchId);
useEffect(() => {
activeWorkbenchIdRef.current = activeWorkbenchId;
}, [activeWorkbenchId]);

async function sendPending(
nonce: string,
text: string,
Expand Down Expand Up @@ -290,7 +300,14 @@ export function useOptimisticSends(args: {
});
},
);
if (pendingParentMessageId !== null) {
// A continuation that started before the reader switched to a
// different workbench must not drag them back into this one's
// thread — `useThreadNavigation` already reset the open thread on
// that switch; re-opening it here would undo that reset (CL-7198).
if (
pendingParentMessageId !== null &&
activeWorkbenchIdRef.current === activeWorkbenchId
) {
openThreadById(threadId);
}
}
Expand Down
51 changes: 39 additions & 12 deletions packages/chat-ui/src/use-workbench-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,9 +501,19 @@ export function useWorkbenchFeed(args: {
}): WorkbenchFeed {
const { tenantId, activeWorkbenchId, onWorkbenchNotFound } = args;
const queryClient = useQueryClient();
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
// Scoped to the (tenantId, workbenchId) the timer was scheduled for —
// not a bare timer handle — so a pending refresh for one workbench can
// never make another workbench's `refreshFeed()` call early-return, and
// never fires an invalidation against a query key that is no longer the
// active one (CL-7198).
const refreshTimerRef = useRef<
| {
readonly tenantId: string;
readonly workbenchId: string;
readonly timer: ReturnType<typeof setTimeout>;
}
| undefined
>(undefined);

// Threads and the mailbox are two queries, and every view the timeline
// can show is derived from them (CL-6313). Each message carries the
Expand Down Expand Up @@ -600,22 +610,39 @@ export function useWorkbenchFeed(args: {
// completes, so invalidating on each one still walks the hub once per
// gap between responses. Collapsing the burst into one refetch per
// window is the difference between ~40 requests per turn and ~2.
if (refreshTimerRef.current !== undefined) return;
refreshTimerRef.current = setTimeout(() => {
refreshTimerRef.current = undefined;
void queryClient.invalidateQueries({
queryKey: chatFeedQueryKeyPrefix(tenantId, activeWorkbenchId),
});
}, CHAT_FEED_COALESCE_MS);
const pending = refreshTimerRef.current;
if (pending !== undefined) {
if (
pending.tenantId === tenantId &&
pending.workbenchId === activeWorkbenchId
) {
return;
}
// A timer scheduled for a workbench the reader has since left —
// clear it rather than let it invalidate that now-inactive query key.
clearTimeout(pending.timer);
}
const workbenchId = activeWorkbenchId;
refreshTimerRef.current = {
tenantId,
workbenchId,
timer: setTimeout(() => {
refreshTimerRef.current = undefined;
void queryClient.invalidateQueries({
queryKey: chatFeedQueryKeyPrefix(tenantId, workbenchId),
});
}, CHAT_FEED_COALESCE_MS),
};
}, [queryClient, tenantId, activeWorkbenchId, isUnauthorized]);

useEffect(
() => () => {
if (refreshTimerRef.current !== undefined) {
clearTimeout(refreshTimerRef.current);
clearTimeout(refreshTimerRef.current.timer);
refreshTimerRef.current = undefined;
}
},
[],
[tenantId, activeWorkbenchId],
);
return {
threads,
Expand Down
104 changes: 104 additions & 0 deletions packages/chat-ui/test/composer-synchronous-double-send.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// CL-7198: the composer's send guard used to test the `sending` *state*
// variable, which `performSend` only sets after the click/keydown handler
// that started it has already returned. Two triggers landing in the same
// synchronous tick (e.g. a stray double dispatch of the send action) both
// read `sending === false` and both post — this proves a second trigger in
// the same tick is turned away regardless of state timing.

import { afterEach, describe, expect, test } from "bun:test";
import { act, createElement, createRef } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";

import { Composer } from "../src/composer";
import type { ComposerHandle, ComposerSendPayload } from "../src/composer";

let container: HTMLDivElement | null = null;
let root: Root | null = null;

afterEach(() => {
if (root !== null) {
act(() => root?.unmount());
root = null;
}
if (container !== null) {
container.remove();
container = null;
}
});

const settle = () =>
act(() => new Promise((resolve) => setTimeout(resolve, 0)));

function mount(onSend: (payload: ComposerSendPayload) => Promise<boolean>) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const ref = createRef<ComposerHandle>();
act(() => {
root?.render(
createElement(Composer, {
ref,
agents: [],
onSend,
onInviteAgent: () => undefined,
onOpenAgentsSettings: () => undefined,
onCreateRoutineInSpace: () => undefined,
}),
);
});
return container;
}

function textarea(): HTMLTextAreaElement {
const element = container?.querySelector("textarea");
if (element === null || element === undefined) {
throw new Error("composer textarea not found");
}
return element;
}

function typeInto(element: HTMLTextAreaElement, text: string) {
const setter = Object.getOwnPropertyDescriptor(
globalThis.HTMLTextAreaElement.prototype,
"value",
)?.set;
act(() => {
setter?.call(element, text);
element.dispatchEvent(new Event("input", { bubbles: true }));
});
}

function sendButton(): HTMLButtonElement {
const button = container?.querySelector<HTMLButtonElement>(
'[aria-label^="Send"]',
);
if (button === null || button === undefined) {
throw new Error("send button not found");
}
return button;
}

describe("Composer synchronous double-send guard (CL-7198)", () => {
test("two clicks in the same tick post exactly one send", async () => {
let sendCount = 0;
const payloads: ComposerSendPayload[] = [];
mount((payload) => {
sendCount += 1;
payloads.push(payload);
return Promise.resolve(true);
});

typeInto(textarea(), "hello there");
await settle();

act(() => {
sendButton().click();
sendButton().click();
});
await settle();

expect(sendCount).toBe(1);
expect(payloads).toEqual([{ text: "hello there", attachments: [] }]);
});
});
Loading
Loading