Skip to content
Closed
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
24 changes: 7 additions & 17 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
endThreadContextDrag,
moveThreadContextDrag as moveThreadContextDragGhost,
} from "./chat/threadContextDrag";
import { releaseComposerDraftUploads } from "../lib/composerDraftUploads";
import { discardComposerDraft } from "../lib/discardComposerDraft";
import { requestCustomSnooze } from "./CustomSnoozeDialog";
import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
Expand Down Expand Up @@ -915,7 +915,6 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
}) {
const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey);
const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey);
const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);
// The open draft's row is FROZEN at the moment the draft became the route:
// it stays visible (like a thread row) but never repaints while the user
// types. A draft that was never navigated away from has no snapshot to
Expand Down Expand Up @@ -979,16 +978,6 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
props.routeDraftId,
props.scopedProjectKeys,
]);
const handleDiscard = useCallback(
(draftId: DraftId) => {
// The /draft/$draftId route redirects home on its own when the draft
// it renders disappears, so discarding the open draft needs no
// special-casing here.
releaseComposerDraftUploads(draftId);
clearDraftThread(draftId);
},
[clearDraftThread],
);
if (drafts.length === 0) {
return null;
}
Expand All @@ -1005,7 +994,10 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
projectDisplayName={props.projectDisplayNameByKey.get(projectKey) ?? null}
isActive={draftId === props.routeDraftId}
onNavigate={props.onNavigateToDraft}
onDiscard={handleDiscard}
// The /draft/$draftId route redirects home on its own when the
// draft it renders disappears, so discarding the open draft needs
// no special-casing here.
onDiscard={discardComposerDraft}
/>
);
})}
Expand Down Expand Up @@ -1164,15 +1156,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
// Unsent composer text on this thread. The open thread shows its own
// composer, so the marker only decorates rows you have navigated away from.
const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isActive;
const clearComposerContent = useComposerDraftStore((store) => store.clearComposerContent);
const handleDiscardDraftClick = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
event.stopPropagation();
releaseComposerDraftUploads(threadRef);
clearComposerContent(threadRef);
discardComposerDraft(threadRef);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium components/Sidebar.tsx:1163

When a draft with uploads is discarded and its content changes before Undo, the failed discardComposerDraft.undo leaves the captured attachments unreleased, leaking pending/server-side uploads. showThreadUndoNotice removes the entry before invoking undo, so its commit hook cannot run later; invoke commit when restoration fails, while still skipping it after a successful restore.

Also found in 1 other location(s)

apps/web/src/hooks/showThreadUndoNotice.ts:36

A failed undo drops the discard entry without running its new commit hook. If the user discards a draft with attachments, then types new content before clicking Undo, discardComposerDraft.undo returns Failure; the group was already removed at line 57, so neither this stale-entry path nor the expiry path can later call releaseDraftAttachments for the removed snapshot. The original uploads continue and any persisted server-side attachments are never released. Invoke commit for entries whose undo fails (while still skipping it after a successful restore).

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/Sidebar.tsx around line 1163:

When a draft with uploads is discarded and its content changes before `Undo`, the failed `discardComposerDraft.undo` leaves the captured attachments unreleased, leaking pending/server-side uploads. `showThreadUndoNotice` removes the entry before invoking `undo`, so its `commit` hook cannot run later; invoke `commit` when restoration fails, while still skipping it after a successful restore.

Also found in 1 other location(s):
- apps/web/src/hooks/showThreadUndoNotice.ts:36 -- A failed undo drops the discard entry without running its new `commit` hook. If the user discards a draft with attachments, then types new content before clicking Undo, `discardComposerDraft.undo` returns `Failure`; the group was already removed at line 57, so neither this stale-entry path nor the expiry path can later call `releaseDraftAttachments` for the removed snapshot. The original uploads continue and any persisted server-side attachments are never released. Invoke `commit` for entries whose undo fails (while still skipping it after a successful restore).

Copy link
Copy Markdown
Contributor Author

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.

Copy link
Copy Markdown
Contributor

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.

},
[clearComposerContent, threadRef],
[threadRef],
);

const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null;
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ export function SidebarThreadUndoNotice() {

if (!notice) return null;
const shortcut = shortcutLabelForCommand(keybindings, "thread.undo");
const noun = `${notice.action === "Discarded" ? "draft" : "thread"}${notice.count === 1 ? "" : "s"}`;

return (
<Alert role="status" variant="sidebar">
<AlertDescription>
{notice.action} {notice.count} thread{notice.count === 1 ? "" : "s"},{" "}
{notice.action} {notice.count} {noun},{" "}
<InlineButton onClick={undoLatestThreadAction}>
{shortcut ? `${shortcut} to undo` : "Undo"}
</InlineButton>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/composerDraftStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1504,7 +1504,7 @@ function normalizeComposerTarget(
return target;
}

function resolveComposerDraftKey(
export function resolveComposerDraftKey(
state: ComposerThreadLookupState,
target: ComposerThreadTarget,
): string | null {
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/hooks/showThreadUndoNotice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast";
import * as ThreadUndo from "./threadUndo";

type UndoOptions = {
action: "Settled" | "Snoozed" | "Unpinned" | "Archived";
action: "Settled" | "Snoozed" | "Unpinned" | "Archived" | "Discarded";
undo: () => Promise<AtomCommandResult<unknown, unknown>>;
failureTitle: string;
claim: ReturnType<typeof ThreadUndo.begin>;
/** Runs once the action can no longer be undone. */
commit?: () => void;
};

type UndoNotice = {
Expand All @@ -29,7 +31,9 @@ let liveUndos: UndoOptions[] = [];
let expiry: ReturnType<typeof setTimeout> | undefined;

function refreshNotice() {
liveUndos = liveUndos.filter(({ claim }) => claim.isCurrent());
const stale = liveUndos.filter(({ claim }) => !claim.isCurrent());
liveUndos = liveUndos.filter((entry) => !stale.includes(entry));
for (const entry of stale) entry.commit?.();
const latest = liveUndos.at(-1);
if (!latest) {
clearTimeout(expiry);
Expand Down Expand Up @@ -100,7 +104,10 @@ export function showThreadUndoNotice(options: UndoOptions) {
expiry = setTimeout(() => {
const expired = liveUndos;
liveUndos = [];
for (const { claim } of expired) claim.finish();
for (const { claim, commit } of expired) {
claim.finish();
commit?.();
}
refreshNotice();
}, 5_000);
}
89 changes: 89 additions & 0 deletions apps/web/src/lib/discardComposerDraft.test.ts
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" }),
);
});
});
79 changes: 79 additions & 0 deletions apps/web/src/lib/discardComposerDraft.ts
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.")));
Comment thread
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]),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium lib/discardComposerDraft.ts:77

When the page reloads or closes during the 5-second undo window, the persisted discard leaves pending uploads unreferenced but releaseDraftAttachments is never called, so they remain on the server until the sweep. The in-memory notice and its commit timer are lost on unload; persist pending discards and reconcile them on startup, or otherwise release them during unload.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/lib/discardComposerDraft.ts around line 77:

When the page reloads or closes during the 5-second undo window, the persisted discard leaves pending uploads unreferenced but `releaseDraftAttachments` is never called, so they remain on the server until the sweep. The in-memory notice and its `commit` timer are lost on unload; persist pending discards and reconcile them on startup, or otherwise release them during unload.

Copy link
Copy Markdown
Contributor Author

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

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: sweepStalePendingAttachments (apps/server/src/attachmentStore.ts) deletes stale pending attachments, and config.ts runs it. Before this PR, closing the tab with an unsent draft left its uploads to that same sweep. Persisting pending discards and reconciling them on startup would add a second storage path for a rare window that the sweep already covers.

Copy link
Copy Markdown
Contributor

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.

});
}
3 changes: 2 additions & 1 deletion docs/user/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ a shortcut.
shortcut; assign one in **Settings → Keybindings**.

`thread.undo` (`mod+z` by default) reverses the actions shown in the notice at the
bottom of the sidebar, such as unpin, settle, snooze, or archive. Consecutive
bottom of the sidebar, such as unpin, settle, snooze, archive, or discarding a
draft. Consecutive
actions of the same kind undo together. The notice remains available for five
seconds after the latest action. The default shortcut skips text fields and
terminals so native undo keeps working there.
Expand Down
3 changes: 2 additions & 1 deletion docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ Pin a thread from its menu to keep it above your active work.
On web and desktop, unpinning, settling, snoozing, and archiving a thread each show
a notification with **Undo** for five seconds. Undo restores the thread's previous
state, including its pinned position, and reopens an archived thread you were
viewing. `mod+z` triggers the most recent Undo when no text field is focused; see
viewing. Discarding an unsent draft from the sidebar works the same way: Undo brings
back its text and attachments. `mod+z` triggers the most recent Undo when no text field is focused; see
[Keybindings](./keybindings.md#commands-with-special-behavior).

On web and desktop, you can also drag files from your computer onto any thread row:
Expand Down
Loading