diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/__tests__/chatDraft.test.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/__tests__/chatDraft.test.tsx index ee37cd39417f..cc48fae080e7 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/__tests__/chatDraft.test.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/__tests__/chatDraft.test.tsx @@ -197,4 +197,20 @@ describe("acting on a draft without showing it", () => { setItem.mockRestore(); } }); + it("keeps no copy of a draft once browser storage holds it", () => { + const draft = renderHook(() => useChatDraftAccess("session:chat-stored")); + const setItem = vi + .spyOn(window.sessionStorage, "setItem") + .mockImplementation(() => { + throw new Error("QuotaExceededError"); + }); + act(() => draft.result.current.write("Refused by storage")); + setItem.mockRestore(); + act(() => draft.result.current.write("Stored")); + + sessionStorage.clear(); + + // Storage is what holds the draft now, so clearing it clears the draft. + expect(draft.result.current.read()).toBe(""); + }); }); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/useChatDraft.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/useChatDraft.ts index 8f64e6a90b73..39fff5aac050 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/useChatDraft.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/useChatDraft.ts @@ -2,8 +2,8 @@ import { useEffect, useRef, useState, type SetStateAction } from "react"; import { useAuthStore } from "@/lib/auth/hooks/useAuthStore"; const subscribers = new Map void>>(); -// Each draft as last set on this page, for a reader that doesn't follow it -// while browser storage is unavailable. +// Drafts browser storage refused, for a reader that doesn't follow the draft. +// A draft leaves once storage takes a later write of it. const written = new Map(); function readDraft(key: string | null) { @@ -17,12 +17,13 @@ function readDraft(key: string | null) { function writeDraft(key: string | null, value: string) { if (!key || typeof window === "undefined") return; - written.set(key, value); try { if (value) sessionStorage.setItem(key, value); else sessionStorage.removeItem(key); + written.delete(key); } catch { /* Keep the in-memory draft when browser storage is unavailable. */ + written.set(key, value); } subscribers.get(key)?.forEach((receive) => receive(value)); } diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/SendAtReset/__tests__/useAtResetJobs.test.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/SendAtReset/__tests__/useAtResetJobs.test.tsx index 5e9bc5421c79..0c280a68cbbb 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/SendAtReset/__tests__/useAtResetJobs.test.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/SendAtReset/__tests__/useAtResetJobs.test.tsx @@ -70,4 +70,30 @@ describe("useAtResetJobs", () => { vi.useRealTimers(); } }); + + it("looks at a turn hours away only now and then", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(JOB.run_at.getTime() - 3 * 60 * 60_000); + let reads = 0; + server.use( + http.get("*/api/chat/sessions/:sessionId/at-reset", () => { + reads += 1; + return HttpResponse.json({ jobs: [JOB] }); + }), + ); + try { + renderHook(() => useAtResetJobs("session-1"), { + wrapper: withQueryClient(), + }); + await vi.waitFor(() => expect(reads).toBe(1)); + + await act(() => vi.advanceTimersByTimeAsync(10 * 60_000)); + expect(reads).toBe(1); + + await act(() => vi.advanceTimersByTimeAsync(5 * 60_000)); + expect(reads).toBe(2); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/SendAtReset/__tests__/useScheduledSend.test.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/SendAtReset/__tests__/useScheduledSend.test.tsx index e2b9664cd0f5..7742406b6fb8 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/SendAtReset/__tests__/useScheduledSend.test.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/SendAtReset/__tests__/useScheduledSend.test.tsx @@ -304,4 +304,30 @@ describe("useScheduledSend", () => { }); }); }); + + // The client throws on error statuses, so this answer is the only way the + // server can leave a send unset without an error; the composer has already + // cleared the message by then. + it("puts the message back when the server answers without the send", async () => { + server.use( + http.get("*/api/chat/sessions/:sessionId/at-reset", () => + HttpResponse.json({ jobs: [] }), + ), + http.post("*/api/chat/sessions/:sessionId/at-reset", () => + HttpResponse.json(null, { status: 202 }), + ), + ); + const { result } = renderHook( + () => useScheduledSend({ sessionId: "chat-unset" }), + { wrapper: withQueryClient() }, + ); + const composer = renderHook(() => + useChatDraft(sessionDraftKey("chat-unset")), + ); + + await expect(result.current.schedule("Draft the post")).rejects.toThrow(); + await act(() => result.current.scheduleQuietly("Draft the post")); + + expect(composer.result.current[0]).toBe("Draft the post"); + }); }); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/SendAtReset/useAtResetJobs.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/SendAtReset/useAtResetJobs.ts index 2b1b29392405..7d286724ce59 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/SendAtReset/useAtResetJobs.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/SendAtReset/useAtResetJobs.ts @@ -42,10 +42,16 @@ export function useAtResetJobs(sessionId: string | null) { return { jobs, writeJobs }; } +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; + function refetchEvery(jobs: AtResetTurn[]) { if (!jobs.length) return false; const untilDue = Math.min(...jobs.map((job) => new Date(job.run_at).getTime())) - Date.now(); if (untilDue <= 0) return 10_000; - return Math.min(Math.max(untilDue, 1_000), 60_000); + // Changes made here update the list directly, and it's read again on + // focus, so a turn hours away only needs an occasional look. + const ceiling = untilDue > HOUR ? 15 * MINUTE : MINUTE; + return Math.min(Math.max(untilDue, 1_000), ceiling); } diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/SendAtReset/useScheduledSend.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/SendAtReset/useScheduledSend.ts index fdd2ce418ea8..10e791fad2b4 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/SendAtReset/useScheduledSend.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/SendAtReset/useScheduledSend.ts @@ -63,7 +63,10 @@ export function useScheduledSend({ sessionId, onSendNow, inputId }: Args) { const response = await scheduleSend .mutateAsync({ sessionId, data: { kind: "send", message } }) .finally(onAnswered); - if (response.status !== 200) return; + // The client throws on error statuses. Any other answer without the + // job leaves the message unset, so it fails the same way and the + // message goes back to whoever cleared it. + if (response.status !== 200) throw new Error(); const job = response.data; await writeJobs((all) => [...all.filter((j) => j.kind !== "send"), job]); } catch (error) {