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
101 changes: 74 additions & 27 deletions apps/web/src/lib/discardComposerDraft.test.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,69 @@
import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { toastManager } from "../components/ui/toast";
import { DraftId, useComposerDraftStore } from "../composerDraftStore";
import { useThreadUndoNotice } from "../hooks/showThreadUndoNotice";
import { releaseDraftAttachments } from "./attachmentUploadQueue";
import {
type ComposerImageAttachment,
DraftId,
useComposerDraftStore,
} from "../composerDraftStore";
import { showThreadUndoNotice } from "../hooks/showThreadUndoNotice";
import { discardComposerDraft } from "./discardComposerDraft";

vi.mock("./attachmentUploadQueue", () => ({ releaseDraftAttachments: vi.fn() }));
vi.mock("../hooks/showThreadUndoNotice", () => ({ showThreadUndoNotice: vi.fn() }));

const environmentId = EnvironmentId.make("environment-local");
const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1"));
const draftId = DraftId.make("draft-1");
const threadRef = scopeThreadRef(environmentId, ThreadId.make("thread-1"));

function undoNotice() {
const notice = useThreadUndoNotice.getState().notice;
if (!notice) throw new Error("Undo notice is missing");
return notice;
const options = vi.mocked(showThreadUndoNotice).mock.calls.at(-1)?.[0];
if (!options) throw new Error("Undo notice is missing");
return options;
}

function imagePreview(name = "image.png") {
const file = new File(["image bytes"], name, { type: "image/png" });
const previewUrl = URL.createObjectURL(file);
return {
image: {
type: "image",
id: name,
name,
mimeType: file.type,
sizeBytes: file.size,
file,
previewUrl,
} satisfies ComposerImageAttachment,
[Symbol.dispose]() {
URL.revokeObjectURL(previewUrl);
},
};
}

beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
useComposerDraftStore.setState({
draftsByThreadKey: {},
draftThreadsByThreadKey: {},
logicalProjectDraftThreadKeyByLogicalProjectKey: {},
});
});
afterEach(() => {
vi.runAllTimers();
vi.useRealTimers();
vi.clearAllMocks();
vi.restoreAllMocks();
});

describe("discardComposerDraft", () => {
it("restores a discarded new-thread draft with its project mapping", async () => {
it("restores a discarded new-thread draft with its project mapping and a usable preview", async () => {
using preview = imagePreview();
const store = useComposerDraftStore.getState();
store.setProjectDraftThreadId(projectRef, draftId);
store.setPrompt(draftId, "half-written prompt");
store.addImage(draftId, preview.image);
const before = useComposerDraftStore.getState();

discardComposerDraft(draftId);
expect(useComposerDraftStore.getState().getDraftSession(draftId)).toBeNull();
expect(undoNotice()).toMatchObject({ action: "Discarded", count: 1 });
await expect(fetch(preview.image.previewUrl)).rejects.toThrow();

await undoNotice().undo();
const after = useComposerDraftStore.getState();
Expand All @@ -54,21 +72,50 @@ describe("discardComposerDraft", () => {
expect(after.logicalProjectDraftThreadKeyByLogicalProjectKey).toEqual(
before.logicalProjectDraftThreadKeyByLogicalProjectKey,
);
vi.runAllTimers();
expect(releaseDraftAttachments).not.toHaveBeenCalled();
const restoredUrl = after.getComposerDraft(draftId)!.images[0]!.previewUrl;
try {
expect(await (await fetch(restoredUrl)).text()).toBe("image bytes");
} finally {
URL.revokeObjectURL(restoredUrl);
}
});

it("releases a discarded existing-thread preview when Undo is committed", async () => {
using preview = imagePreview();
useComposerDraftStore.getState().addImage(threadRef, preview.image);

discardComposerDraft(threadRef);
expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.images ?? []).toEqual([]);
expect(await (await fetch(preview.image.previewUrl)).text()).toBe("image bytes");

undoNotice().commit?.();
await expect(fetch(preview.image.previewUrl)).rejects.toThrow();
});

it("keeps text typed after the discard and releases the old uploads", async () => {
useComposerDraftStore.getState().setPrompt(threadRef, "old reply");
it("keeps newer draft content and releases the discarded preview when Undo is refused", async () => {
using oldPreview = imagePreview("old.png");
using newPreview = imagePreview("new.png");
useComposerDraftStore.getState().addImage(threadRef, oldPreview.image);
discardComposerDraft(threadRef);
useComposerDraftStore.getState().setPrompt(threadRef, "new reply");
const addToast = vi.spyOn(toastManager, "add").mockReturnValue("error-toast");
useComposerDraftStore.getState().addImage(threadRef, newPreview.image);

await undoNotice().undo();
const result = await undoNotice().undo();
expect(result._tag).toBe("Failure");
expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt).toBe("new reply");
expect(releaseDraftAttachments).toHaveBeenCalledOnce();
expect(addToast).toHaveBeenCalledWith(
expect.objectContaining({ title: "Failed to restore draft" }),
);
await expect(fetch(oldPreview.image.previewUrl)).rejects.toThrow();
expect(await (await fetch(newPreview.image.previewUrl)).text()).toBe("image bytes");
});

it("keeps an existing-thread preview usable after successful Undo", async () => {
using preview = imagePreview();
useComposerDraftStore.getState().addImage(threadRef, preview.image);
discardComposerDraft(threadRef);

const result = await undoNotice().undo();
expect(result._tag).toBe("Success");
const restored = useComposerDraftStore.getState().getComposerDraft(threadRef)!.images[0]!;
expect(restored.previewUrl).toBe(preview.image.previewUrl);
expect(await (await fetch(restored.previewUrl)).text()).toBe("image bytes");
});
});
14 changes: 12 additions & 2 deletions apps/web/src/lib/discardComposerDraft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,24 @@ export function discardComposerDraft(target: ComposerThreadTarget): void {
store.clearComposerContent(target);
}

const releaseDiscardedDraft = () => {
releaseDraftAttachments([...draft.images, ...draft.files]);
// Draft-session removal already revoked these previews.
if (!discardsSession) {
for (const image of draft.images) {
if (image.previewUrl.startsWith("blob:")) URL.revokeObjectURL(image.previewUrl);
}
}
};

showThreadUndoNotice({
action: "Discarded",
claim,
failureTitle: "Failed to restore draft",
undo: async () => {
const current = useComposerDraftStore.getState().draftsByThreadKey[key];
if (current && composerDraftHasUserContent(current)) {
releaseDraftAttachments([...draft.images, ...draft.files]);
releaseDiscardedDraft();
return AsyncResult.failure(Cause.fail(new Error("The draft has new content.")));
}
useComposerDraftStore.setState((state) => {
Expand Down Expand Up @@ -69,6 +79,6 @@ export function discardComposerDraft(target: ComposerThreadTarget): void {
});
return AsyncResult.success(undefined);
},
commit: () => releaseDraftAttachments([...draft.images, ...draft.files]),
commit: releaseDiscardedDraft,
});
}
Loading