mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-11-10 12:51:43 -10:00
fix(frontend/copilot): keep a send handed to a new chat until the request setting it settles (#15592)
Fixes SECRT-2960 (part of SECRT-2958, the usage-limits stack's follow-ups). ### Why / What / How **Why:** Send at reset on the new-chat screen creates the chat, then hands the message to the new chat's composer through `scheduledSendHandoff` (kept in session storage since #15403). The composer's `useHandedOffSend` took the message out of the store and posted it to `/at-reset`. A reload while that request was in flight left the message in neither the store nor the composer's draft, so if the request never reached the server, it was lost. CodeRabbit raised it on #15403 (https://github.com/Significant-Gravitas/AutoGPT/pull/15403#discussion_r4290117913). **What:** the handoff now ends only once the server has answered the request setting the send. A page that finds a handed-off send another page already sent (the one before a reload) asks the server instead of sending it again: - the waiting send holds the message (alone or joined onto others): nothing to do, beyond reading the chat's list again so the waiting send shows; - it doesn't: the message goes back in the composer, with the usual "Couldn't schedule your message" toast; - the server can't be asked: the message goes back in the composer, with "Couldn't check your scheduled message" and "It may already be set to send". Before deciding, it gives the gone page's request time to land: the server keeps on with a request after a reload drops the page, and can take seconds over it. It never retries, because the first request could still land and the server would join the message on twice. **How:** - `scheduledSendHandoff`: entries are `{ message, sentAt }`; `hold`, `markSent`, `clear` replace `take`. The persisted state is versioned, and a version-0 entry (a bare message) reads as not yet sent. - `useHandedOffSend`: a page marks the send as sent, sets it, then clears the entry once the server answers. `schedule` and `scheduleQuietly` take an `onAnswered` callback for this, run as `mutateAsync` settles. It's needed when the server says the budget is back: the message then goes out now, and `sendMessage`'s promise only settles once the whole reply has streamed. Clearing at the end of that left the entry marked sent through the reply, so a reload during it put the already-sent message back in the composer (found by Sentry and Pwuts). A module-level set records the sends this page is setting, so a re-render or Strict Mode's second effect doesn't send twice, and a send marked sent that isn't in the set came from a page that has gone. - `useScheduledSend.recover(message, sentAt)`, plus `holdsMessage` and `readHolds` in `helpers.ts` (the server joins each send onto the one waiting after a blank line). If the first read doesn't find the message, `recover` waits until `SEND_SETTLE_MS` (15 s) after `sentAt` and reads again before putting it back (found by Sentry). A send already landed is found on the first read. If the user cancels a send holding the handed-off message during the wait, with Edit or Cancel (the request can land and show meanwhile), `recover` stands down. For this the page notes the text of each send it cancels (`noteCancel` and `startCancelWatch` in `sendChanges.ts`). Any other change doesn't stand in for the handed-off message, such as a message the user adds, or adds and then cancels. `recover` reads again and puts the handed-off message back unless the send holds it (found by Sentry and Pwuts). - `readSend` (used by Edit since #15403, and now by `recover`) makes its own request instead of joining the list query's. When the chat remounts, React Query cancels the list's in-flight request, and a read riding on it came back as "can't reach the server". The new test caught this under Strict Mode. - **Edit and a message added mid-read** (found by Capy on this PR). A message the composer or the "send at reset" menu adds while Edit's read is out can be missing from that read. Edit would then cancel the joined send and put back only the old text. This was already possible on dev. The shared read there was cancelled when a scheduling write landed (`cancelQueries` reverts by default), but query-core 5.90 resolves a reverted fetch on a query that already has data with that old data, so Edit went on with the old send. And a write still in flight when the read lands was never covered. Put back on the shared read, Edit fails both mid-read tests below. `sendChanges.ts` now counts the send changes the page starts on each chat, shared by every `useScheduledSend` (the composer and the menu each make one). Edit acts only on a read that no change can have overtaken, whether the change started during the read or was still out when the read began. Otherwise it says the message "changed just now" and leaves it alone. `recover` doesn't need the check: adding to the send doesn't add the handed-off message, and Edit puts what it cancels in the draft. - The read stays out of the list's cache, so a late one can't put an older list back (the resume-at-reset card writes that list too). Edit reads the list again when it finds the send gone, and `recover` does when it finds the send landed: the list, read as the chat opened, can predate it, and a list holding no send isn't polled. - `useHandedOffSend` keeps its actions in a ref updated by an effect rather than during render, so the handoff uses a committed render's actions (CodeRabbit). **Left out:** a request the server takes longer than 15 s over still ends up both set and in the composer, and sending it again would join it on twice. Closing that needs a request id the server records as the request starts, which the page could look for. It needs backend support, so it's a follow-up. ### Changes 🏗️ - `SendAtReset/scheduledSendHandoff.ts`, `useHandedOffSend.ts`, `useScheduledSend.ts` (`recover`, `readSend`, Edit's check, `onAnswered`), `sendChanges.ts` (new), `helpers.ts` (`holdsMessage`) - `ChatContainer/useComposerBudget.ts`: passes the scheduled-send actions to `useHandedOffSend` - Tests: `useHandedOffSend.test.tsx` (new), `useScheduledSend.test.tsx`, `scheduledSendHandoff.test.ts`, `helpers.test.ts` ### Agents and large language models used - Claude Code with Claude Opus 5.5 ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] A handed-off send is set once and the handoff cleared, under Strict Mode; while the request is out, the handoff holds it marked sent - [x] When the server says the budget is back, the handoff is gone as soon as the message goes out now, with its reply still streaming: no draft, no toast - [x] After a reload mid-request: kept when the server's waiting send holds it (joined onto an earlier one), and shown though the chat's list was read before it landed; kept when the request lands only after the first read; left alone when the user cancels or edits it during the wait; put back when the original never lands and the user adds a message of their own, or adds and then cancels or edits it; back in the composer when it doesn't hold it, and when the server can't be asked, with "It may already be set to send"; no second request in any case - [x] The handoff survives a reload until cleared; a version-0 entry reads as not yet sent - [x] The two recovery toasts keep their own titles ("Couldn't schedule your message", "Couldn't check your scheduled message"), and the kept case shows none - [x] Edit leaves a send alone when the menu adds a message during Edit's read, both when the add lands first and when the read does: no cancel, nothing put back, and the joined send still waiting. Once a change from the page has settled, Edit works again - [x] Mutation checks: sending again instead of asking the server fails all three reload cases; the old cache-sharing read fails the "server has it" case; dropping Edit's check fails both mid-read cases; ignoring changes still out fails the read-first case; counting a change only once it settles fails the add-first case; swapping the recovery titles fails both toast checks. Each fails its own test: never marking the send sent, clearing it before the request settles, a change that never settles, keeping the handoff through the live reply (two ways), not reading the list again after `recover`, "Try again" when the server can't be asked, skipping the second read for a request still landing, ignoring a cancel during the wait, a cancel noted nowhere, treating any cancel as one of the handed-off message, and Cancel or Edit dropping the text it cancels - [x] Send at reset, ChatContainer, ChatInput, TurnPausedCard, UsageLimits, provider-limit and rate-limit dialog suites, and the copilot page tests: 1,076 tests in 90 files pass under `TZ=UTC` (plus 2 expected failures) - [x] prettier, eslint and `tsc --noEmit` clean #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
This commit is contained in:
1 parent
604916b4c6
commit
a1578ad9ad
10 files changed
+871
-53
No files matched your search
+1
-1
@@ -37,7 +37,7 @@ export function useComposerBudget({
|
||||
isPlatformChat &&
|
||||
getResetWait(budget) !== null;
|
||||
const scheduled = useScheduledSend({ sessionId, onSendNow: onSend, inputId });
|
||||
useHandedOffSend(sessionId, scheduled.scheduleQuietly);
|
||||
useHandedOffSend(sessionId, scheduled);
|
||||
|
||||
// A new chat has no session to set the send on: create it, then hand the
|
||||
// message to its composer, which sets the send once the page is on it.
|
||||
|
||||
+16
@@ -2,6 +2,7 @@ import { ApiError } from "@/lib/autogpt-server-api/helpers";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
atResetRefusal,
|
||||
holdsMessage,
|
||||
isJoinedSendTooLong,
|
||||
returnToDraft,
|
||||
SCHEDULE_FAILED_TITLE,
|
||||
@@ -89,3 +90,18 @@ describe("sendAtResetFailureTitle", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("holdsMessage", () => {
|
||||
it("finds a message alone or joined anywhere in the send waiting", () => {
|
||||
expect(holdsMessage("Draft", "Draft")).toBe(true);
|
||||
expect(holdsMessage("Draft\n\nLater", "Draft")).toBe(true);
|
||||
expect(holdsMessage("Earlier\n\nDraft", "Draft")).toBe(true);
|
||||
expect(holdsMessage("Earlier\n\nDraft\n\nLater", "Draft")).toBe(true);
|
||||
});
|
||||
|
||||
it("doesn't take a message that only starts the same for it", () => {
|
||||
expect(holdsMessage("Draft the post", "Draft")).toBe(false);
|
||||
expect(holdsMessage("Earlier\n\nDraft the post", "Draft")).toBe(false);
|
||||
expect(holdsMessage("", "Draft")).toBe(false);
|
||||
});
|
||||
});
|
||||
+28
-5
@@ -1,5 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const STORE_KEY = "copilot-scheduled-send-handoff";
|
||||
|
||||
async function freshStore() {
|
||||
vi.resetModules();
|
||||
const { useScheduledSendHandoff } = await import("../scheduledSendHandoff");
|
||||
@@ -17,17 +19,38 @@ describe("useScheduledSendHandoff", () => {
|
||||
|
||||
const after = await freshStore();
|
||||
|
||||
expect(after.getState().take("chat-new")).toBe("Draft the launch post");
|
||||
expect(after.getState().take("chat-new")).toBeNull();
|
||||
expect(after.getState().sends["chat-new"]).toEqual({
|
||||
message: "Draft the launch post",
|
||||
sentAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("hands a message over once, even after a reload", async () => {
|
||||
it("keeps a send through a reload until the request setting it settles", async () => {
|
||||
const before = await freshStore();
|
||||
before.getState().hold("chat-new", "Draft the launch post");
|
||||
expect(before.getState().take("chat-new")).toBe("Draft the launch post");
|
||||
before.getState().markSent("chat-new");
|
||||
|
||||
const after = await freshStore();
|
||||
expect(after.getState().sends["chat-new"]?.sentAt).not.toBeNull();
|
||||
|
||||
expect(after.getState().take("chat-new")).toBeNull();
|
||||
after.getState().clear("chat-new");
|
||||
expect((await freshStore()).getState().sends["chat-new"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads a message kept before sends recorded when they were sent", async () => {
|
||||
sessionStorage.setItem(
|
||||
STORE_KEY,
|
||||
JSON.stringify({
|
||||
state: { messages: { "chat-old": "Draft the launch post" } },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const store = await freshStore();
|
||||
|
||||
expect(store.getState().sends["chat-old"]).toEqual({
|
||||
message: "Draft the launch post",
|
||||
sentAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
+425
@@ -0,0 +1,425 @@
|
||||
import {
|
||||
getGetV2ListTurnsAtBudgetResetQueryKey,
|
||||
type getV2ListTurnsAtBudgetResetResponse,
|
||||
} from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import { server } from "@/mocks/mock-server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { StrictMode, type ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useChatDraft } from "../../ChatInput/useChatDraft";
|
||||
import { SEND_SETTLE_MS, sessionDraftKey } from "../helpers";
|
||||
import { useScheduledSendHandoff } from "../scheduledSendHandoff";
|
||||
import { useHandedOffSend } from "../useHandedOffSend";
|
||||
import { useScheduledSend } from "../useScheduledSend";
|
||||
|
||||
vi.mock("@/lib/auth/hooks/useAuthStore", () => ({
|
||||
useAuthStore: (selector: (state: { user: { id: string } }) => unknown) =>
|
||||
selector({ user: { id: "user-a" } }),
|
||||
}));
|
||||
|
||||
const toast = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/components/molecules/Toast/use-toast", () => ({
|
||||
toast,
|
||||
useToast: () => ({ toast, dismiss: vi.fn() }),
|
||||
}));
|
||||
|
||||
const RUN_AT = new Date(2026, 9, 17, 1, 7).toISOString();
|
||||
const MESSAGE = "Draft the launch post";
|
||||
|
||||
function withQueryClient(
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } }),
|
||||
) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function useNewChat(
|
||||
sessionId: string,
|
||||
onSendNow?: (message: string) => Promise<void>,
|
||||
) {
|
||||
const scheduled = useScheduledSend({ sessionId, onSendNow });
|
||||
useHandedOffSend(sessionId, scheduled);
|
||||
const [draft] = useChatDraft(sessionDraftKey(sessionId));
|
||||
return {
|
||||
draft,
|
||||
pending: scheduled.pending,
|
||||
schedule: scheduled.schedule,
|
||||
cancel: scheduled.cancel,
|
||||
edit: scheduled.edit,
|
||||
};
|
||||
}
|
||||
|
||||
function serverHolding(waiting: unknown[] | "unavailable") {
|
||||
const calls = { posted: [] as unknown[] };
|
||||
server.use(
|
||||
http.get("*/api/chat/sessions/:sessionId/at-reset", () =>
|
||||
waiting === "unavailable"
|
||||
? HttpResponse.json(
|
||||
{ detail: "Scheduler unavailable" },
|
||||
{ status: 503 },
|
||||
)
|
||||
: HttpResponse.json({ jobs: waiting }),
|
||||
),
|
||||
http.post(
|
||||
"*/api/chat/sessions/:sessionId/at-reset",
|
||||
async ({ request }) => {
|
||||
const body = (await request.json()) as { message: string };
|
||||
calls.posted.push(body);
|
||||
return HttpResponse.json({
|
||||
kind: "send",
|
||||
run_at: RUN_AT,
|
||||
message: body.message,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
return calls;
|
||||
}
|
||||
|
||||
function handedOff(sessionId: string) {
|
||||
return useScheduledSendHandoff.getState().sends[sessionId];
|
||||
}
|
||||
|
||||
function toastTitles() {
|
||||
return toast.mock.calls.map(([options]) => options.title);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
useScheduledSendHandoff.setState({ sends: {} });
|
||||
toast.mockClear();
|
||||
});
|
||||
|
||||
describe("useHandedOffSend", () => {
|
||||
it("sets a send the new-chat screen handed over once, then lets it go", async () => {
|
||||
const calls = serverHolding([]);
|
||||
useScheduledSendHandoff.getState().hold("chat-new", MESSAGE);
|
||||
|
||||
renderHook(() => useNewChat("chat-new"), { wrapper: withQueryClient() });
|
||||
|
||||
await waitFor(() => expect(handedOff("chat-new")).toBeUndefined());
|
||||
expect(calls.posted).toEqual([{ kind: "send", message: MESSAGE }]);
|
||||
});
|
||||
|
||||
it("keeps it, marked sent, until the request setting it settles", async () => {
|
||||
let release = () => {};
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let posted = 0;
|
||||
server.use(
|
||||
http.get("*/api/chat/sessions/:sessionId/at-reset", () =>
|
||||
HttpResponse.json({ jobs: [] }),
|
||||
),
|
||||
http.post("*/api/chat/sessions/:sessionId/at-reset", async () => {
|
||||
posted += 1;
|
||||
await held;
|
||||
return HttpResponse.json({
|
||||
kind: "send",
|
||||
run_at: RUN_AT,
|
||||
message: MESSAGE,
|
||||
});
|
||||
}),
|
||||
);
|
||||
useScheduledSendHandoff.getState().hold("chat-in-flight", MESSAGE);
|
||||
|
||||
renderHook(() => useNewChat("chat-in-flight"), {
|
||||
wrapper: withQueryClient(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(posted).toBe(1));
|
||||
// What a reload now would find.
|
||||
expect(handedOff("chat-in-flight")).toEqual({
|
||||
message: MESSAGE,
|
||||
sentAt: expect.any(Number),
|
||||
});
|
||||
release();
|
||||
await waitFor(() => expect(handedOff("chat-in-flight")).toBeUndefined());
|
||||
});
|
||||
|
||||
// The budget came back meanwhile, so the message goes out now and its reply
|
||||
// streams long after the server answered. A reload during the reply mustn't
|
||||
// find the message and put it back in the composer.
|
||||
it("lets it go once the server says to send it now", async () => {
|
||||
server.use(
|
||||
http.get("*/api/chat/sessions/:sessionId/at-reset", () =>
|
||||
HttpResponse.json({ jobs: [] }),
|
||||
),
|
||||
http.post("*/api/chat/sessions/:sessionId/at-reset", () =>
|
||||
HttpResponse.json({ detail: "not_out_of_budget" }, { status: 409 }),
|
||||
),
|
||||
);
|
||||
const sendNow = vi.fn(() => new Promise<void>(() => {}));
|
||||
useScheduledSendHandoff.getState().hold("chat-budget-back", MESSAGE);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useNewChat("chat-budget-back", sendNow),
|
||||
{ wrapper: withQueryClient() },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(sendNow).toHaveBeenCalledWith(MESSAGE));
|
||||
expect(handedOff("chat-budget-back")).toBeUndefined();
|
||||
expect(result.current.draft).toBe("");
|
||||
expect(toastTitles()).toEqual([]);
|
||||
});
|
||||
|
||||
describe("after a reload while the send was being set", () => {
|
||||
// By default long enough ago that its request has landed if it ever will.
|
||||
function sentBeforeTheReload(sessionId: string, ago = SEND_SETTLE_MS) {
|
||||
useScheduledSendHandoff.setState({
|
||||
sends: { [sessionId]: { message: MESSAGE, sentAt: Date.now() - ago } },
|
||||
});
|
||||
}
|
||||
|
||||
// The chat's list as the page read it on opening: no send, and fresh, so
|
||||
// only the page's own reads of the send reach the server.
|
||||
function listedEmpty(sessionId: string) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
client.setQueryData<getV2ListTurnsAtBudgetResetResponse>(
|
||||
getGetV2ListTurnsAtBudgetResetQueryKey(sessionId),
|
||||
{ status: 200, data: { jobs: [] }, headers: new Headers() },
|
||||
);
|
||||
return client;
|
||||
}
|
||||
|
||||
it("keeps it when the server has it, joined onto what was waiting", async () => {
|
||||
const calls = serverHolding([
|
||||
{ kind: "send", run_at: RUN_AT, message: `Earlier note\n\n${MESSAGE}` },
|
||||
]);
|
||||
sentBeforeTheReload("chat-landed");
|
||||
|
||||
const { result } = renderHook(() => useNewChat("chat-landed"), {
|
||||
wrapper: withQueryClient(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(handedOff("chat-landed")).toBeUndefined());
|
||||
expect(calls.posted).toEqual([]);
|
||||
expect(result.current.draft).toBe("");
|
||||
expect(toastTitles()).toEqual([]);
|
||||
});
|
||||
|
||||
// The chat's list, read as the page opened, can predate the send landing,
|
||||
// and nothing reads a list holding no send again.
|
||||
it("shows the send it finds the server has", async () => {
|
||||
const client = listedEmpty("chat-listed-early");
|
||||
serverHolding([{ kind: "send", run_at: RUN_AT, message: MESSAGE }]);
|
||||
sentBeforeTheReload("chat-listed-early");
|
||||
|
||||
const { result } = renderHook(() => useNewChat("chat-listed-early"), {
|
||||
wrapper: withQueryClient(client),
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.pending?.message).toBe(MESSAGE),
|
||||
);
|
||||
expect(handedOff("chat-listed-early")).toBeUndefined();
|
||||
expect(result.current.draft).toBe("");
|
||||
});
|
||||
|
||||
// The server keeps on with a request after a reload drops the page that
|
||||
// sent it, so a send missing just after the reload can still land.
|
||||
it("waits for a request still landing before putting it back", async () => {
|
||||
// The clock stands still, so the page reads before the window ends
|
||||
// however long it takes to open; the wait itself runs in real time.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
try {
|
||||
vi.setSystemTime(new Date(2026, 9, 18, 9, 0));
|
||||
const client = listedEmpty("chat-landing");
|
||||
let reads = 0;
|
||||
server.use(
|
||||
http.get("*/api/chat/sessions/:sessionId/at-reset", () => {
|
||||
reads += 1;
|
||||
return HttpResponse.json({
|
||||
jobs:
|
||||
reads > 1
|
||||
? [{ kind: "send", run_at: RUN_AT, message: MESSAGE }]
|
||||
: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
sentBeforeTheReload("chat-landing", SEND_SETTLE_MS - 300);
|
||||
|
||||
const { result } = renderHook(() => useNewChat("chat-landing"), {
|
||||
wrapper: withQueryClient(client),
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => expect(result.current.pending?.message).toBe(MESSAGE),
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
expect(handedOff("chat-landing")).toBeUndefined();
|
||||
expect(result.current.draft).toBe("");
|
||||
expect(toastTitles()).toEqual([]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
describe("when the user changes the send during the wait", () => {
|
||||
beforeEach(() => {
|
||||
// The clock stands still, so the page reads before the window ends
|
||||
// however long it takes to open; the wait itself runs in real time.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date(2026, 9, 18, 9, 0));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// The page has read the send once and waits for the gone page's
|
||||
// request, which lands right after that read if `lands`.
|
||||
async function waitingChat(sessionId: string, lands: boolean) {
|
||||
const calls = { reads: 0, cancelled: 0 };
|
||||
let waiting: string | null = null;
|
||||
server.use(
|
||||
http.get("*/api/chat/sessions/:sessionId/at-reset", () => {
|
||||
calls.reads += 1;
|
||||
const jobs = waiting
|
||||
? [{ kind: "send", run_at: RUN_AT, message: waiting }]
|
||||
: [];
|
||||
if (lands && calls.reads === 1) waiting = MESSAGE;
|
||||
return HttpResponse.json({ jobs });
|
||||
}),
|
||||
http.post(
|
||||
"*/api/chat/sessions/:sessionId/at-reset",
|
||||
async ({ request }) => {
|
||||
const body = (await request.json()) as { message: string };
|
||||
waiting = waiting
|
||||
? `${waiting}\n\n${body.message}`
|
||||
: body.message;
|
||||
return HttpResponse.json({
|
||||
kind: "send",
|
||||
run_at: RUN_AT,
|
||||
message: waiting,
|
||||
});
|
||||
},
|
||||
),
|
||||
http.delete("*/api/chat/sessions/:sessionId/at-reset/:kind", () => {
|
||||
calls.cancelled += 1;
|
||||
waiting = null;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
sentBeforeTheReload(sessionId, SEND_SETTLE_MS - 1000);
|
||||
const client = listedEmpty(sessionId);
|
||||
const { result } = renderHook(() => useNewChat(sessionId), {
|
||||
wrapper: withQueryClient(client),
|
||||
});
|
||||
await waitFor(() => expect(calls.reads).toBe(1));
|
||||
return { calls, client, result };
|
||||
}
|
||||
|
||||
// The request landed, the chip showed it, and the user cancelled or
|
||||
// edited it: putting the message back would undo what they did.
|
||||
it.each(["cancel", "edit"] as const)(
|
||||
"leaves a send that landed to the user once they %s it",
|
||||
async (action) => {
|
||||
const sessionId = `chat-landed-${action}`;
|
||||
const { calls, client, result } = await waitingChat(sessionId, true);
|
||||
await act(() =>
|
||||
client.invalidateQueries({
|
||||
queryKey: getGetV2ListTurnsAtBudgetResetQueryKey(sessionId),
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.pending?.message).toBe(MESSAGE),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current[action]();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(handedOff(sessionId)).toBeUndefined(), {
|
||||
timeout: 3000,
|
||||
});
|
||||
expect(calls.cancelled).toBe(1);
|
||||
expect(result.current.draft).toBe(action === "edit" ? MESSAGE : "");
|
||||
expect(toastTitles()).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
// A message the user sets meanwhile doesn't stand in for the one the
|
||||
// gone page's request was carrying, which never landed.
|
||||
it("still puts it back once the user adds a message of their own", async () => {
|
||||
const { result } = await waitingChat("chat-added-to", false);
|
||||
|
||||
await act(() => result.current.schedule("Then the newsletter"));
|
||||
|
||||
await waitFor(() => expect(result.current.draft).toBe(MESSAGE), {
|
||||
timeout: 3000,
|
||||
});
|
||||
expect(handedOff("chat-added-to")).toBeUndefined();
|
||||
expect(toastTitles()).toEqual(["Couldn’t schedule your message"]);
|
||||
});
|
||||
|
||||
// Nor does cancelling or editing that message: it never held this one.
|
||||
it.each(["cancel", "edit"] as const)(
|
||||
"still puts it back once the user %ss a message of their own",
|
||||
async (action) => {
|
||||
const sessionId = `chat-own-${action}`;
|
||||
const { calls, result } = await waitingChat(sessionId, false);
|
||||
await act(() => result.current.schedule("Then the newsletter"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current[action]();
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(result.current.draft).toBe(
|
||||
action === "edit"
|
||||
? `${MESSAGE}\n\nThen the newsletter`
|
||||
: MESSAGE,
|
||||
),
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
expect(calls.cancelled).toBe(1);
|
||||
expect(handedOff(sessionId)).toBeUndefined();
|
||||
expect(toastTitles()).toEqual(["Couldn’t schedule your message"]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("puts it back in the composer when the server doesn't have it", async () => {
|
||||
const calls = serverHolding([]);
|
||||
sentBeforeTheReload("chat-lost");
|
||||
|
||||
const { result } = renderHook(() => useNewChat("chat-lost"), {
|
||||
wrapper: withQueryClient(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.draft).toBe(MESSAGE));
|
||||
expect(handedOff("chat-lost")).toBeUndefined();
|
||||
expect(calls.posted).toEqual([]);
|
||||
expect(toastTitles()).toEqual(["Couldn’t schedule your message"]);
|
||||
});
|
||||
|
||||
it("puts it back in the composer when the server can't be asked", async () => {
|
||||
const calls = serverHolding("unavailable");
|
||||
sentBeforeTheReload("chat-unsure");
|
||||
|
||||
const { result } = renderHook(() => useNewChat("chat-unsure"), {
|
||||
wrapper: withQueryClient(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.draft).toBe(MESSAGE));
|
||||
expect(calls.posted).toEqual([]);
|
||||
expect(toastTitles()).toEqual(["Couldn’t check your scheduled message"]);
|
||||
// Sending it again could join it on twice, so the toast says why not.
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: expect.stringContaining("It may already be set to send"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+138
@@ -165,5 +165,143 @@ describe("useScheduledSend", () => {
|
||||
expect(cancelled).toBe(0);
|
||||
expect(composer.result.current[0]).toBe("");
|
||||
});
|
||||
|
||||
// The composer's Edit and the "send at reset" menu each change the same
|
||||
// chat's send. A message the menu adds while Edit reads may not be in
|
||||
// what Edit read, and cancelling that copy would drop the message.
|
||||
describe("with a message added while it reads", () => {
|
||||
const FIRST = "Draft the launch post";
|
||||
const JOINED = `${FIRST}\n\nThen the newsletter`;
|
||||
|
||||
// The server holds the first message, then both once the add lands. A
|
||||
// read answers with what the server held when the read came in.
|
||||
function serverJoining() {
|
||||
const calls = { reads: 0, added: 0, cancelled: 0 };
|
||||
const hold: { nextRead?: Promise<void>; add?: Promise<void> } = {};
|
||||
let waiting: string | null = FIRST;
|
||||
server.use(
|
||||
http.get("*/api/chat/sessions/:sessionId/at-reset", async () => {
|
||||
calls.reads += 1;
|
||||
const answer = waiting;
|
||||
const held = hold.nextRead;
|
||||
hold.nextRead = undefined;
|
||||
await held;
|
||||
return HttpResponse.json({
|
||||
jobs: answer
|
||||
? [{ kind: "send", run_at: RUN_AT, message: answer }]
|
||||
: [],
|
||||
});
|
||||
}),
|
||||
http.post("*/api/chat/sessions/:sessionId/at-reset", async () => {
|
||||
calls.added += 1;
|
||||
await hold.add;
|
||||
waiting = JOINED;
|
||||
return HttpResponse.json({
|
||||
kind: "send",
|
||||
run_at: RUN_AT,
|
||||
message: JOINED,
|
||||
});
|
||||
}),
|
||||
http.delete("*/api/chat/sessions/:sessionId/at-reset/:kind", () => {
|
||||
calls.cancelled += 1;
|
||||
waiting = null;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
return { calls, hold };
|
||||
}
|
||||
|
||||
async function openChat(sessionId: string) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
const chat = {
|
||||
composer: renderHook(() => useScheduledSend({ sessionId }), {
|
||||
wrapper,
|
||||
}),
|
||||
menu: renderHook(() => useScheduledSend({ sessionId }), { wrapper }),
|
||||
draft: renderHook(() => useChatDraft(sessionDraftKey(sessionId))),
|
||||
};
|
||||
await waitFor(() =>
|
||||
expect(chat.composer.result.current.pending?.message).toBe(FIRST),
|
||||
);
|
||||
return chat;
|
||||
}
|
||||
|
||||
function gate() {
|
||||
let release = () => {};
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return { closed, release };
|
||||
}
|
||||
|
||||
it("leaves it alone when the message lands before the read", async () => {
|
||||
const { calls, hold } = serverJoining();
|
||||
const chat = await openChat("chat-added-during-read");
|
||||
const read = gate();
|
||||
hold.nextRead = read.closed;
|
||||
const readsBefore = calls.reads;
|
||||
|
||||
let editing = Promise.resolve();
|
||||
act(() => {
|
||||
editing = chat.composer.result.current.edit();
|
||||
});
|
||||
await waitFor(() => expect(calls.reads).toBe(readsBefore + 1));
|
||||
await act(() =>
|
||||
chat.menu.result.current.schedule("Then the newsletter"),
|
||||
);
|
||||
read.release();
|
||||
await act(() => editing);
|
||||
|
||||
expect(calls.cancelled).toBe(0);
|
||||
expect(chat.draft.result.current[0]).toBe("");
|
||||
await waitFor(() =>
|
||||
expect(chat.composer.result.current.pending?.message).toBe(JOINED),
|
||||
);
|
||||
});
|
||||
|
||||
it("still edits once a change it made has settled", async () => {
|
||||
const { calls } = serverJoining();
|
||||
const chat = await openChat("chat-edit-after-add");
|
||||
await act(() =>
|
||||
chat.menu.result.current.schedule("Then the newsletter"),
|
||||
);
|
||||
|
||||
await act(() => chat.composer.result.current.edit());
|
||||
|
||||
expect(calls.cancelled).toBe(1);
|
||||
expect(chat.draft.result.current[0]).toBe(JOINED);
|
||||
});
|
||||
|
||||
it("leaves it alone when the read lands before the message", async () => {
|
||||
const { calls, hold } = serverJoining();
|
||||
const chat = await openChat("chat-read-during-add");
|
||||
const add = gate();
|
||||
hold.add = add.closed;
|
||||
|
||||
let adding = Promise.resolve();
|
||||
act(() => {
|
||||
adding = chat.menu.result.current.schedule("Then the newsletter");
|
||||
});
|
||||
await waitFor(() => expect(calls.added).toBe(1));
|
||||
await act(() => chat.composer.result.current.edit());
|
||||
add.release();
|
||||
await act(() => adding);
|
||||
|
||||
expect(calls.cancelled).toBe(0);
|
||||
expect(chat.draft.result.current[0]).toBe("");
|
||||
await waitFor(() =>
|
||||
expect(chat.composer.result.current.pending?.message).toBe(JOINED),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+24
@@ -1,3 +1,4 @@
|
||||
import type { AtResetTurn } from "@/app/api/__generated__/models/atResetTurn";
|
||||
import { ApiError } from "@/lib/autogpt-server-api/helpers";
|
||||
import {
|
||||
formatResetClock,
|
||||
@@ -8,6 +9,10 @@ export const SEND_AT_RESET_LABEL = "Send at reset";
|
||||
export const SCHEDULE_FAILED_TITLE = "Couldn’t schedule your message";
|
||||
export const SEND_AT_RESET_PLACEHOLDER =
|
||||
"Write now, and it sends when your budget resets…";
|
||||
/** How long after a page sends a message to be set the request may still
|
||||
* land: the server can take seconds over it, and keeps going after a reload
|
||||
* drops the page. */
|
||||
export const SEND_SETTLE_MS = 15_000;
|
||||
|
||||
/** Thrown when the budget turned out to be back, so the message went out
|
||||
* straight away instead, and that send failed; `cause` is how it failed. */
|
||||
@@ -66,3 +71,22 @@ export function returnToDraft(current: string, message: string) {
|
||||
if (!current.trim() || current === message) return message;
|
||||
return `${message}\n\n${current}`;
|
||||
}
|
||||
|
||||
/** Whether the send waiting holds `message`: the server joins each send set
|
||||
* while one waits onto it, after a blank line. */
|
||||
export function holdsMessage(waiting: string, message: string) {
|
||||
return (
|
||||
waiting === message ||
|
||||
waiting.startsWith(`${message}\n\n`) ||
|
||||
waiting.endsWith(`\n\n${message}`) ||
|
||||
waiting.includes(`\n\n${message}\n\n`)
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a read of the chat's send found `message` in it. */
|
||||
export function readHolds(
|
||||
read: { send: AtResetTurn | null } | { error: unknown },
|
||||
message: string,
|
||||
) {
|
||||
return "send" in read && holdsMessage(read.send?.message ?? "", message);
|
||||
}
|
||||
+50
-15
@@ -4,45 +4,80 @@ import { createJSONStorage, persist } from "zustand/middleware";
|
||||
/**
|
||||
* "Send at reset" from the new-chat screen: the chat is created first, and
|
||||
* creating it remounts the page under the new session id. The message waits
|
||||
* here until that session's composer picks it up and sets the send. The
|
||||
* composer that took it has already cleared, so the wait is kept in the
|
||||
* tab's session storage: a reload in between still sets the send.
|
||||
* here until that session's composer has set the send. The composer that took
|
||||
* it has already cleared, so the wait is kept in the tab's session storage
|
||||
* and only ends once the request that sets the send has settled: a reload
|
||||
* before then, or while it's in flight, still finds the message.
|
||||
*/
|
||||
interface HandedOffSend {
|
||||
message: string;
|
||||
/** When a page sent it to be set; null until then. */
|
||||
sentAt: number | null;
|
||||
}
|
||||
|
||||
interface ScheduledSendHandoff {
|
||||
messages: Record<string, string>;
|
||||
sends: Record<string, HandedOffSend>;
|
||||
hold: (sessionId: string, message: string) => void;
|
||||
take: (sessionId: string) => string | null;
|
||||
markSent: (sessionId: string) => void;
|
||||
clear: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export const useScheduledSendHandoff = create<ScheduledSendHandoff>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
messages: {},
|
||||
(set) => ({
|
||||
sends: {},
|
||||
hold(sessionId, message) {
|
||||
set((state) => ({
|
||||
messages: { ...state.messages, [sessionId]: message },
|
||||
sends: { ...state.sends, [sessionId]: { message, sentAt: null } },
|
||||
}));
|
||||
},
|
||||
take(sessionId) {
|
||||
const message = get().messages[sessionId] ?? null;
|
||||
if (message === null) return null;
|
||||
markSent(sessionId) {
|
||||
set((state) => {
|
||||
const rest = { ...state.messages };
|
||||
const send = state.sends[sessionId];
|
||||
if (!send) return state;
|
||||
return {
|
||||
sends: {
|
||||
...state.sends,
|
||||
[sessionId]: { ...send, sentAt: Date.now() },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
clear(sessionId) {
|
||||
set((state) => {
|
||||
const rest = { ...state.sends };
|
||||
delete rest[sessionId];
|
||||
return { messages: rest };
|
||||
return { sends: rest };
|
||||
});
|
||||
return message;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "copilot-scheduled-send-handoff",
|
||||
version: 1,
|
||||
// Version 0 kept bare messages that no page had sent yet.
|
||||
migrate: (persisted) => ({ sends: migrateSends(persisted) }),
|
||||
// No-op storage where there is no window (SSR), as in the stream store.
|
||||
storage: createJSONStorage(() =>
|
||||
typeof window !== "undefined" && window.sessionStorage
|
||||
? window.sessionStorage
|
||||
: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
),
|
||||
partialize: (state) => ({ messages: state.messages }),
|
||||
partialize: (state) => ({ sends: state.sends }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
function migrateSends(persisted: unknown): Record<string, HandedOffSend> {
|
||||
const messages =
|
||||
persisted && typeof persisted === "object" && "messages" in persisted
|
||||
? (persisted as { messages: unknown }).messages
|
||||
: null;
|
||||
if (!messages || typeof messages !== "object") return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(messages)
|
||||
.filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
)
|
||||
.map(([sessionId, message]) => [sessionId, { message, sentAt: null }]),
|
||||
);
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { holdsMessage } from "./helpers";
|
||||
|
||||
// The send changes this page has started on each chat, and how many are
|
||||
// still out. Kept for the whole page: the composer and the "send at reset"
|
||||
// menu each change the same chat's send.
|
||||
const changes = new Map<string, { started: number; out: number }>();
|
||||
// The waits on each chat's send, each told the text of a send this page
|
||||
// cancels while it waits.
|
||||
const cancelWatches = new Map<string, Set<(cancelled: string) => void>>();
|
||||
|
||||
/** Mutation callbacks that count each change to a chat's send. */
|
||||
export const countSendChanges = {
|
||||
onMutate({ sessionId }: { sessionId: string }) {
|
||||
const chat = changesTo(sessionId);
|
||||
chat.started += 1;
|
||||
chat.out += 1;
|
||||
},
|
||||
onSettled(_data: unknown, _error: unknown, variables: { sessionId: string }) {
|
||||
changesTo(variables.sessionId).out -= 1;
|
||||
},
|
||||
};
|
||||
|
||||
/** Call as this page cancels the chat's send, with the text it cancels. */
|
||||
export function noteCancel(sessionId: string, cancelled: string) {
|
||||
cancelWatches.get(sessionId)?.forEach((watch) => watch(cancelled));
|
||||
}
|
||||
|
||||
/** Call as a read of the chat's send goes out; once it is back, the
|
||||
* returned check says whether it can have missed a change from this page. */
|
||||
export function startSendRead(sessionId: string) {
|
||||
const chat = changesTo(sessionId);
|
||||
const startedBefore = chat.out === 0 ? chat.started : null;
|
||||
return function canHaveMissedAChange() {
|
||||
return startedBefore === null || chat.started !== startedBefore;
|
||||
};
|
||||
}
|
||||
|
||||
/** Call as a wait on the chat's send begins; the returned check ends the
|
||||
* wait and says whether this page has since cancelled a send holding
|
||||
* `message`. */
|
||||
export function startCancelWatch(sessionId: string, message: string) {
|
||||
let cancelled = false;
|
||||
function watch(text: string) {
|
||||
if (holdsMessage(text, message)) cancelled = true;
|
||||
}
|
||||
const watches = cancelWatches.get(sessionId) ?? new Set();
|
||||
watches.add(watch);
|
||||
cancelWatches.set(sessionId, watches);
|
||||
return function cancelledSince() {
|
||||
watches.delete(watch);
|
||||
if (watches.size === 0) cancelWatches.delete(sessionId);
|
||||
return cancelled;
|
||||
};
|
||||
}
|
||||
|
||||
function changesTo(sessionId: string) {
|
||||
let chat = changes.get(sessionId);
|
||||
if (!chat) {
|
||||
chat = { started: 0, out: 0 };
|
||||
changes.set(sessionId, chat);
|
||||
}
|
||||
return chat;
|
||||
}
|
||||
+37
-10
@@ -1,19 +1,46 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useScheduledSendHandoff } from "./scheduledSendHandoff";
|
||||
|
||||
interface Actions {
|
||||
/** Sets the send, or puts the message back in the draft if it can't;
|
||||
* calls `onAnswered` once the server has answered. */
|
||||
scheduleQuietly: (message: string, onAnswered?: () => void) => Promise<void>;
|
||||
/** For a send a page sent at `sentAt` and lost track of: see whether it
|
||||
* was set. */
|
||||
recover: (message: string, sentAt: number) => Promise<void>;
|
||||
}
|
||||
|
||||
// Sessions this page is setting a handed-off send for right now. A send
|
||||
// marked sent that isn't here was sent by a page that has since gone.
|
||||
const settingSends = new Set<string>();
|
||||
|
||||
/** Sets the send a new-chat screen handed this session when it created it. */
|
||||
export function useHandedOffSend(
|
||||
sessionId: string | null,
|
||||
scheduleQuietly: (message: string) => Promise<void>,
|
||||
) {
|
||||
export function useHandedOffSend(sessionId: string | null, actions: Actions) {
|
||||
const waiting = useScheduledSendHandoff((state) =>
|
||||
sessionId ? (state.messages[sessionId] ?? null) : null,
|
||||
sessionId ? (state.sends[sessionId] ?? null) : null,
|
||||
);
|
||||
const latest = useRef(scheduleQuietly);
|
||||
latest.current = scheduleQuietly;
|
||||
const latest = useRef(actions);
|
||||
// Before the handoff's effect, so it uses the actions of a committed render.
|
||||
useEffect(() => {
|
||||
if (!sessionId || waiting === null) return;
|
||||
const message = useScheduledSendHandoff.getState().take(sessionId);
|
||||
if (message !== null) void latest.current(message);
|
||||
latest.current = actions;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!sessionId || waiting === null || settingSends.has(sessionId)) return;
|
||||
settingSends.add(sessionId);
|
||||
const handoff = useScheduledSendHandoff.getState();
|
||||
const { message, sentAt } = waiting;
|
||||
if (sentAt === null) handoff.markSent(sessionId);
|
||||
// Once the server has answered, the chat holds the message: set for the
|
||||
// reset, back in the draft, or sent now, its reply streaming long after.
|
||||
const clear = () => handoff.clear(sessionId);
|
||||
// Retrying a send a gone page may have set could add it twice.
|
||||
const settling =
|
||||
sentAt === null
|
||||
? latest.current.scheduleQuietly(message, clear)
|
||||
: latest.current.recover(message, sentAt);
|
||||
void settling.finally(() => {
|
||||
settingSends.delete(sessionId);
|
||||
clear();
|
||||
});
|
||||
}, [sessionId, waiting]);
|
||||
}
|
||||
+89
-22
@@ -15,11 +15,20 @@ import { useChatDraftAccess } from "../ChatInput/useChatDraft";
|
||||
import {
|
||||
atResetRefusal,
|
||||
isJoinedSendTooLong,
|
||||
readHolds,
|
||||
returnToDraft,
|
||||
SCHEDULE_FAILED_TITLE,
|
||||
SEND_SETTLE_MS,
|
||||
sendAtResetFailureTitle,
|
||||
SentNowError,
|
||||
sessionDraftKey,
|
||||
} from "./helpers";
|
||||
import {
|
||||
countSendChanges,
|
||||
noteCancel,
|
||||
startCancelWatch,
|
||||
startSendRead,
|
||||
} from "./sendChanges";
|
||||
import { useAtResetJobs } from "./useAtResetJobs";
|
||||
|
||||
interface Args {
|
||||
@@ -36,19 +45,24 @@ export function useScheduledSend({ sessionId, onSendNow, inputId }: Args) {
|
||||
const draft = useChatDraftAccess(
|
||||
sessionId ? sessionDraftKey(sessionId) : undefined,
|
||||
);
|
||||
const scheduleSend = usePostV2ScheduleTurnAtBudgetReset();
|
||||
const cancelSend = useDeleteV2CancelTurnAtBudgetReset();
|
||||
const scheduleSend = usePostV2ScheduleTurnAtBudgetReset({
|
||||
mutation: countSendChanges,
|
||||
});
|
||||
const cancelSend = useDeleteV2CancelTurnAtBudgetReset({
|
||||
mutation: countSendChanges,
|
||||
});
|
||||
const pending = jobs?.find((job) => job.kind === "send") ?? null;
|
||||
|
||||
async function schedule(message: string) {
|
||||
/** `onAnswered` hears as soon as the server answers, before a send it
|
||||
* says to make now instead streams its reply. */
|
||||
async function schedule(message: string, onAnswered?: () => void) {
|
||||
if (!sessionId) throw new Error("This chat isn’t ready yet");
|
||||
// A chat sends one message at the reset; the server adds this one to
|
||||
// any already waiting and answers with the joined message.
|
||||
try {
|
||||
const response = await scheduleSend.mutateAsync({
|
||||
sessionId,
|
||||
data: { kind: "send", message },
|
||||
});
|
||||
const response = await scheduleSend
|
||||
.mutateAsync({ sessionId, data: { kind: "send", message } })
|
||||
.finally(onAnswered);
|
||||
if (response.status !== 200) return;
|
||||
const job = response.data;
|
||||
await writeJobs((all) => [...all.filter((j) => j.kind !== "send"), job]);
|
||||
@@ -78,9 +92,9 @@ export function useScheduledSend({ sessionId, onSendNow, inputId }: Args) {
|
||||
|
||||
/** For a message no composer is holding: a failure goes back into the
|
||||
* chat's draft instead of being thrown. */
|
||||
async function scheduleQuietly(message: string) {
|
||||
async function scheduleQuietly(message: string, onAnswered?: () => void) {
|
||||
try {
|
||||
await schedule(message);
|
||||
await schedule(message, onAnswered);
|
||||
} catch (error) {
|
||||
draft.write((current) => returnToDraft(current, message));
|
||||
toastSendFailure({
|
||||
@@ -91,8 +105,10 @@ export function useScheduledSend({ sessionId, onSendNow, inputId }: Args) {
|
||||
}
|
||||
}
|
||||
|
||||
async function unschedule() {
|
||||
/** Cancels the chat's send, whose text this page knows as `cancelling`. */
|
||||
async function unschedule(cancelling: string) {
|
||||
if (!sessionId) return false;
|
||||
noteCancel(sessionId, cancelling);
|
||||
try {
|
||||
await cancelSend.mutateAsync({ sessionId, kind: "send" });
|
||||
} catch (error) {
|
||||
@@ -110,34 +126,49 @@ export function useScheduledSend({ sessionId, onSendNow, inputId }: Args) {
|
||||
toastChangeFailure(read.error);
|
||||
return;
|
||||
}
|
||||
// Nor with a copy that can predate a message this page added meanwhile.
|
||||
if (read.canHaveMissedAChange) {
|
||||
toastChangeFailure(new Error("It changed just now"));
|
||||
return;
|
||||
}
|
||||
const { send } = read;
|
||||
// Already sent, or cancelled elsewhere: the read has updated the list.
|
||||
if (!send) return;
|
||||
if (!(await unschedule())) return;
|
||||
// Already sent, or cancelled elsewhere.
|
||||
if (!send) {
|
||||
rereadList();
|
||||
return;
|
||||
}
|
||||
if (!(await unschedule(send.message ?? ""))) return;
|
||||
draft.write((current) => returnToDraft(current, send.message ?? ""));
|
||||
if (inputId) document.getElementById(inputId)?.focus();
|
||||
}
|
||||
|
||||
/** The send as the server holds it now, since another tab or client may
|
||||
* have added to it after this one last read the list. */
|
||||
* have added to it after this one last read the list. A request of its
|
||||
* own: one the list's query is making is cancelled if the chat remounts,
|
||||
* which would read as the server being unreachable. It stays out of the
|
||||
* list's cache, where a change made while it was out is newer. */
|
||||
async function readSend() {
|
||||
if (!sessionId) return { send: pending };
|
||||
if (!sessionId) return { send: pending, canHaveMissedAChange: false };
|
||||
const canHaveMissedAChange = startSendRead(sessionId);
|
||||
try {
|
||||
const response = await queryClient.fetchQuery({
|
||||
queryKey: getGetV2ListTurnsAtBudgetResetQueryKey(sessionId),
|
||||
queryFn: ({ signal }) =>
|
||||
getV2ListTurnsAtBudgetReset(sessionId, { signal }),
|
||||
staleTime: 0,
|
||||
});
|
||||
const response = await getV2ListTurnsAtBudgetReset(sessionId);
|
||||
if (response.status !== 200) return { error: response };
|
||||
return {
|
||||
send: response.data.jobs.find((job) => job.kind === "send") ?? null,
|
||||
canHaveMissedAChange: canHaveMissedAChange(),
|
||||
};
|
||||
} catch (error) {
|
||||
return { error };
|
||||
}
|
||||
}
|
||||
|
||||
function rereadList() {
|
||||
if (!sessionId) return;
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListTurnsAtBudgetResetQueryKey(sessionId),
|
||||
});
|
||||
}
|
||||
|
||||
function toastChangeFailure(error: unknown) {
|
||||
toast({
|
||||
title: "Couldn’t change your scheduled message",
|
||||
@@ -146,12 +177,48 @@ export function useScheduledSend({ sessionId, onSendNow, inputId }: Args) {
|
||||
});
|
||||
}
|
||||
|
||||
/** For a message a page sent to be set at `sentAt` and then lost track
|
||||
* of, as on a reload mid-request: keep it if the server has it, otherwise
|
||||
* put it back in the chat's draft. Never sent again from here, since the
|
||||
* first try could still land and the server would add the message twice. */
|
||||
async function recover(message: string, sentAt: number) {
|
||||
if (!sessionId) return;
|
||||
let read = await readSend();
|
||||
const settling = sentAt + SEND_SETTLE_MS - Date.now();
|
||||
if (!readHolds(read, message) && settling > 0) {
|
||||
// The gone page's request can still be landing.
|
||||
const cancelledSince = startCancelWatch(sessionId, message);
|
||||
await new Promise((resolve) => setTimeout(resolve, settling));
|
||||
// Edited or cancelled on this page meanwhile, holding the message:
|
||||
// what the user did with it stands. A send without it, such as a
|
||||
// message they added, doesn't stand in for this one.
|
||||
if (cancelledSince()) return;
|
||||
read = await readSend();
|
||||
}
|
||||
if (readHolds(read, message)) {
|
||||
// The list, read as the chat opened, can predate the send landing.
|
||||
rereadList();
|
||||
return;
|
||||
}
|
||||
draft.write((current) => returnToDraft(current, message));
|
||||
const unread = "error" in read;
|
||||
toastSendFailure({
|
||||
title: unread
|
||||
? "Couldn’t check your scheduled message"
|
||||
: SCHEDULE_FAILED_TITLE,
|
||||
// Sent again, a message the server may hold would be joined on twice.
|
||||
error: unread ? new Error("It may already be set to send") : undefined,
|
||||
recovery: "your message is back in the composer",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
pending,
|
||||
schedule,
|
||||
scheduleQuietly,
|
||||
recover,
|
||||
edit,
|
||||
cancel: unschedule,
|
||||
cancel: () => unschedule(pending?.message ?? ""),
|
||||
isBusy: scheduleSend.isPending || cancelSend.isPending,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user