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
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { useEffect, useRef, useState, type SetStateAction } from "react";
import { useAuthStore } from "@/lib/auth/hooks/useAuthStore";

const subscribers = new Map<string, Set<(value: string) => 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<string, string>();

function readDraft(key: string | null) {
Expand All @@ -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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading