Repository navigation
fix(web): discarding a sidebar draft can be undone #15423
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| 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 { toastManager } from "../components/ui/toast"; | ||
| import { DraftId, useComposerDraftStore } from "../composerDraftStore"; | ||
| import { useThreadUndoNotice } from "../hooks/showThreadUndoNotice"; | ||
| import { releaseDraftAttachments } from "./attachmentUploadQueue"; | ||
| import { discardComposerDraft } from "./discardComposerDraft"; | ||
|
|
||
| vi.mock("./attachmentUploadQueue", () => ({ releaseDraftAttachments: 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; | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| vi.useFakeTimers(); | ||
| 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 () => { | ||
| const store = useComposerDraftStore.getState(); | ||
| store.setProjectDraftThreadId(projectRef, draftId); | ||
| store.setPrompt(draftId, "half-written prompt"); | ||
| const before = useComposerDraftStore.getState(); | ||
|
|
||
| discardComposerDraft(draftId); | ||
| expect(useComposerDraftStore.getState().getDraftSession(draftId)).toBeNull(); | ||
| expect(undoNotice()).toMatchObject({ action: "Discarded", count: 1 }); | ||
|
|
||
| await undoNotice().undo(); | ||
| const after = useComposerDraftStore.getState(); | ||
| expect(after.getDraftSession(draftId)).toEqual(before.getDraftSession(draftId)); | ||
| expect(after.getComposerDraft(draftId)?.prompt).toBe("half-written prompt"); | ||
| expect(after.logicalProjectDraftThreadKeyByLogicalProjectKey).toEqual( | ||
| before.logicalProjectDraftThreadKeyByLogicalProjectKey, | ||
| ); | ||
| vi.runAllTimers(); | ||
| expect(releaseDraftAttachments).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("clears a thread draft for good and releases its uploads once undo expires", async () => { | ||
| useComposerDraftStore.getState().setPrompt(threadRef, "reply in progress"); | ||
|
|
||
| discardComposerDraft(threadRef); | ||
| const notice = undoNotice(); | ||
| expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt ?? "").toBe(""); | ||
| expect(releaseDraftAttachments).not.toHaveBeenCalled(); | ||
|
|
||
| vi.advanceTimersByTime(5_000); | ||
| expect(useThreadUndoNotice.getState().notice).toBeNull(); | ||
| expect(releaseDraftAttachments).toHaveBeenCalledOnce(); | ||
| await notice.undo(); | ||
| expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt ?? "").toBe(""); | ||
| }); | ||
|
|
||
| it("keeps text typed after the discard and releases the old uploads", async () => { | ||
| useComposerDraftStore.getState().setPrompt(threadRef, "old reply"); | ||
| discardComposerDraft(threadRef); | ||
| useComposerDraftStore.getState().setPrompt(threadRef, "new reply"); | ||
| const addToast = vi.spyOn(toastManager, "add").mockReturnValue("error-toast"); | ||
|
|
||
| await undoNotice().undo(); | ||
| expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt).toBe("new reply"); | ||
| expect(releaseDraftAttachments).toHaveBeenCalledOnce(); | ||
| expect(addToast).toHaveBeenCalledWith( | ||
| expect.objectContaining({ title: "Failed to restore draft" }), | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import * as Cause from "effect/Cause"; | ||
| import { AsyncResult } from "effect/unstable/reactivity"; | ||
|
|
||
| import { | ||
| type ComposerThreadTarget, | ||
| composerDraftHasUserContent, | ||
| resolveComposerDraftKey, | ||
| useComposerDraftStore, | ||
| } from "../composerDraftStore"; | ||
| import { showThreadUndoNotice } from "../hooks/showThreadUndoNotice"; | ||
| import * as ThreadUndo from "../hooks/threadUndo"; | ||
| import { releaseDraftAttachments } from "./attachmentUploadQueue"; | ||
|
|
||
| /** | ||
| * Discards a draft's unsent content behind the sidebar undo notice. A new-thread | ||
| * draft (DraftId) loses its whole session; a thread draft only loses its | ||
| * composer content. Uploads are released once the undo window closes. | ||
| */ | ||
| export function discardComposerDraft(target: ComposerThreadTarget): void { | ||
| const store = useComposerDraftStore.getState(); | ||
| const key = resolveComposerDraftKey(store, target); | ||
| const draft = key === null ? undefined : store.draftsByThreadKey[key]; | ||
| if (key === null || !draft) return; | ||
| const session = store.draftThreadsByThreadKey[key]; | ||
| const logicalProjectKeys = Object.entries(store.logicalProjectDraftThreadKeyByLogicalProjectKey) | ||
| .filter(([, draftKey]) => draftKey === key) | ||
| .map(([logicalProjectKey]) => logicalProjectKey); | ||
| const discardsSession = typeof target === "string"; | ||
|
|
||
| const claim = ThreadUndo.begin("discard", key); | ||
| if (discardsSession) { | ||
| store.clearDraftThread(target); | ||
| } else { | ||
| store.clearComposerContent(target); | ||
| } | ||
|
|
||
| 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]); | ||
| return AsyncResult.failure(Cause.fail(new Error("The draft has new content."))); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| useComposerDraftStore.setState((state) => { | ||
| const logicalProjectDraftThreadKeyByLogicalProjectKey = { | ||
| ...state.logicalProjectDraftThreadKeyByLogicalProjectKey, | ||
| }; | ||
| for (const logicalProjectKey of logicalProjectKeys) { | ||
| logicalProjectDraftThreadKeyByLogicalProjectKey[logicalProjectKey] ??= key; | ||
| } | ||
| return { | ||
| draftsByThreadKey: { | ||
| ...state.draftsByThreadKey, | ||
| // Removing a draft session revokes its image previews. | ||
| [key]: discardsSession | ||
| ? { | ||
| ...draft, | ||
| images: draft.images.map((image) => | ||
| image.previewUrl.startsWith("blob:") | ||
| ? { ...image, previewUrl: URL.createObjectURL(image.file) } | ||
| : image, | ||
| ), | ||
| } | ||
| : draft, | ||
| }, | ||
| draftThreadsByThreadKey: session | ||
| ? { ...state.draftThreadsByThreadKey, [key]: session } | ||
| : state.draftThreadsByThreadKey, | ||
| logicalProjectDraftThreadKeyByLogicalProjectKey, | ||
| }; | ||
| }); | ||
| return AsyncResult.success(undefined); | ||
| }, | ||
| commit: () => releaseDraftAttachments([...draft.images, ...draft.files]), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium When the page reloads or closes during the 5-second undo window, the persisted discard leaves pending uploads unreferenced but 🤖 Copy this AI Prompt to have your agent fix this:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note 🤖 Opus 5.5 on behalf of Oliver Leaving this as is. If the page unloads during the 5-second undo window, the only cost is a pending upload that the server already reclaims:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, I'm unable to act on this request because you do not have permissions within this repository. |
||
| }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium
components/Sidebar.tsx:1163When a draft with uploads is discarded and its content changes before
Undo, the faileddiscardComposerDraft.undoleaves the captured attachments unreleased, leaking pending/server-side uploads.showThreadUndoNoticeremoves the entry before invokingundo, so itscommithook cannot run later; invokecommitwhen restoration fails, while still skipping it after a successful restore.Also found in 1 other location(s)
apps/web/src/hooks/showThreadUndoNotice.ts:36🤖 Copy this AI Prompt to have your agent fix this:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note
🤖 Opus 5.5 on behalf of Oliver
Fixed in 3122490 (squashed into the guard commit). The no-overwrite branch of the undo now releases the discarded attachments before it returns the failure, and the "keeps text typed after the discard" test checks that they are released.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.