authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-10-03 19:08:42-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 23:08:42-03:00
log95edeb753b070bad191e10afe15918c969135225
treebbe22aedc24f90314d302671f307a11777c789b7
parentf90b77d80971ea4855a42abfc89ce85466e1accb
signature Signed by PGP key B5690EEEBB952194

fix(web): add context menu to draft threads in the sidebar (#10637)


10 files changed, 326 insertions(+), 29 deletions(-)

apps/web/src/components/Sidebar.tsx+76-21
...@@ -7,7 +7,7 @@ import {...@@ -7,7 +7,7 @@ import {
7 endThreadContextDrag,7 endThreadContextDrag,
8 moveThreadContextDrag as moveThreadContextDragGhost,8 moveThreadContextDrag as moveThreadContextDragGhost,
9} from "./chat/threadContextDrag";9} from "./chat/threadContextDrag";
10import { releaseComposerDraftUploads } from "../lib/composerDraftUploads";10import { discardComposerDraft } from "../lib/discardComposerDraft";
11import { requestCustomSnooze } from "./CustomSnoozeDialog";11import { requestCustomSnooze } from "./CustomSnoozeDialog";
12import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";12import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
13import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";13import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
...@@ -169,7 +169,7 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";...@@ -169,7 +169,7 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
169import { cn } from "~/lib/utils";169import { cn } from "~/lib/utils";
170import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";170import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
171import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge";171import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge";
172import { buildThreadActionMenuItems } from "./threadActionMenu.logic";172import { buildDraftActionMenuItems, buildThreadActionMenuItems } from "./threadActionMenu.logic";
173import {173import {
174 animateSidebarLayoutChanges,174 animateSidebarLayoutChanges,
175 applySidebarThreadDrop,175 applySidebarThreadDrop,
...@@ -798,8 +798,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {...@@ -798,8 +798,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
798 isActive: boolean;798 isActive: boolean;
799 onNavigate: (draftId: DraftId) => void;799 onNavigate: (draftId: DraftId) => void;
800 onDiscard: (draftId: DraftId) => void;800 onDiscard: (draftId: DraftId) => void;
801 onContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void;
801}) {802}) {
802 const { composer, draftId, onDiscard, onNavigate } = props;803 const { composer, draftId, onContextMenu, onDiscard, onNavigate } = props;
803 const promptPreview =804 const promptPreview =
804 replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label)805 replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label)
805 .trim()806 .trim()
...@@ -829,12 +830,23 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {...@@ -829,12 +830,23 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
829 // preventDefault here would swallow Space's synthesized click and830 // preventDefault here would swallow Space's synthesized click and
830 // navigate instead of discarding.831 // navigate instead of discarding.
831 if ((event.target as HTMLElement).closest("button")) return;832 if ((event.target as HTMLElement).closest("button")) return;
832 if (event.key === "Enter" || event.key === " ") {833 if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
834 event.preventDefault();
835 const rect = event.currentTarget.getBoundingClientRect();
836 onContextMenu(draftId, { x: rect.left, y: rect.bottom });
837 } else if (event.key === "Enter" || event.key === " ") {
833 event.preventDefault();838 event.preventDefault();
834 onNavigate(draftId);839 onNavigate(draftId);
835 }840 }
836 },841 },
837 [draftId, onNavigate],842 [draftId, onContextMenu, onNavigate],
843 );
844 const handleContextMenu = useCallback(
845 (event: ReactMouseEvent) => {
846 event.preventDefault();
847 onContextMenu(draftId, { x: event.clientX, y: event.clientY });
848 },
849 [draftId, onContextMenu],
838 );850 );
839 const handleDiscard = useCallback(851 const handleDiscard = useCallback(
840 (event: ReactMouseEvent) => {852 (event: ReactMouseEvent) => {
...@@ -857,6 +869,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {...@@ -857,6 +869,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
857 props.isActive ? "bg-sidebar-row-active" : draftSurfaceClassName,869 props.isActive ? "bg-sidebar-row-active" : draftSurfaceClassName,
858 )}870 )}
859 onClick={handleActivate}871 onClick={handleActivate}
872 onContextMenu={handleContextMenu}
860 onKeyDown={handleKeyDown}873 onKeyDown={handleKeyDown}
861 >874 >
862 <span className="sr-only">{preview}</span>875 <span className="sr-only">{preview}</span>
...@@ -912,10 +925,10 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {...@@ -912,10 +925,10 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
912 scopedProjectKeys: ReadonlySet<string> | null;925 scopedProjectKeys: ReadonlySet<string> | null;
913 routeDraftId: string | null;926 routeDraftId: string | null;
914 onNavigateToDraft: (draftId: DraftId) => void;927 onNavigateToDraft: (draftId: DraftId) => void;
928 onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void;
915}) {929}) {
916 const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey);930 const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey);
917 const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey);931 const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey);
918 const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);
919 // The open draft's row is FROZEN at the moment the draft became the route:932 // The open draft's row is FROZEN at the moment the draft became the route:
920 // it stays visible (like a thread row) but never repaints while the user933 // it stays visible (like a thread row) but never repaints while the user
921 // types. A draft that was never navigated away from has no snapshot to934 // types. A draft that was never navigated away from has no snapshot to
...@@ -979,16 +992,6 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {...@@ -979,16 +992,6 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
979 props.routeDraftId,992 props.routeDraftId,
980 props.scopedProjectKeys,993 props.scopedProjectKeys,
981 ]);994 ]);
982 const handleDiscard = useCallback(
983 (draftId: DraftId) => {
984 // The /draft/$draftId route redirects home on its own when the draft
985 // it renders disappears, so discarding the open draft needs no
986 // special-casing here.
987 releaseComposerDraftUploads(draftId);
988 clearDraftThread(draftId);
989 },
990 [clearDraftThread],
991 );
992 if (drafts.length === 0) {995 if (drafts.length === 0) {
993 return null;996 return null;
994 }997 }
...@@ -1005,7 +1008,11 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {...@@ -1005,7 +1008,11 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
1005 projectDisplayName={props.projectDisplayNameByKey.get(projectKey) ?? null}1008 projectDisplayName={props.projectDisplayNameByKey.get(projectKey) ?? null}
1006 isActive={draftId === props.routeDraftId}1009 isActive={draftId === props.routeDraftId}
1007 onNavigate={props.onNavigateToDraft}1010 onNavigate={props.onNavigateToDraft}
1008 onDiscard={handleDiscard}1011 // The /draft/$draftId route redirects home on its own when the
1012 // draft it renders disappears, so discarding the open draft needs
1013 // no special-casing here.
1014 onDiscard={discardComposerDraft}
1015 onContextMenu={props.onDraftContextMenu}
1009 />1016 />
1010 );1017 );
1011 })}1018 })}
...@@ -1164,15 +1171,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1164,15 +1171,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1164 // Unsent composer text on this thread. The open thread shows its own1171 // Unsent composer text on this thread. The open thread shows its own
1165 // composer, so the marker only decorates rows you have navigated away from.1172 // composer, so the marker only decorates rows you have navigated away from.
1166 const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isActive;1173 const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isActive;
1167 const clearComposerContent = useComposerDraftStore((store) => store.clearComposerContent);
1168 const handleDiscardDraftClick = useCallback(1174 const handleDiscardDraftClick = useCallback(
1169 (event: ReactMouseEvent) => {1175 (event: ReactMouseEvent) => {
1170 event.preventDefault();1176 event.preventDefault();
1171 event.stopPropagation();1177 event.stopPropagation();
1172 releaseComposerDraftUploads(threadRef);1178 discardComposerDraft(threadRef);
1173 clearComposerContent(threadRef);
1174 },1179 },
1175 [clearComposerContent, threadRef],1180 [threadRef],
1176 );1181 );
11771182
1178 const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null;1183 const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null;
...@@ -4227,6 +4232,55 @@ export default function Sidebar() {...@@ -4227,6 +4232,55 @@ export default function Sidebar() {
4227 ],4232 ],
4228 );4233 );
42294234
4235 const handleDraftContextMenu = useCallback(
4236 (draftId: DraftId, position: { x: number; y: number }) => {
4237 void (async () => {
4238 const api = readLocalApi();
4239 const session = useComposerDraftStore.getState().getDraftSession(draftId);
4240 if (!api || !session || session.promotedTo) return;
4241 const projectGroup = projectGroupsRef.current.find((group) =>
4242 group.memberProjectRefs.some(
4243 (ref) =>
4244 ref.environmentId === session.environmentId && ref.projectId === session.projectId,
4245 ),
4246 );
4247 const workspacePath =
4248 session.worktreePath ??
4249 projectByKey.get(`${session.environmentId}:${session.projectId}`)?.workspaceRoot;
4250 const clicked = await settlePromise(() =>
4251 api.contextMenu.show(
4252 buildDraftActionMenuItems({
4253 hasPath: Boolean(workspacePath),
4254 hasBranch: Boolean(session.branch),
4255 hasProject: projectGroup != null,
4256 }),
4257 position,
4258 ),
4259 );
4260 if (clicked._tag === "Failure") return;
4261 switch (clicked.value) {
4262 case "project-settings":
4263 if (projectGroup) openProjectSettings(projectGroup);
4264 return;
4265 case "copy-path":
4266 if (workspacePath) copyPathToClipboard(workspacePath, { path: workspacePath });
4267 return;
4268 case "copy-branch":
4269 if (session.branch) copyBranchToClipboard(session.branch, { branch: session.branch });
4270 return;
4271 case "discard": {
4272 // The menu can stay open while the draft sends; discarding a
4273 // promoting draft would strand the send.
4274 const current = useComposerDraftStore.getState().getDraftSession(draftId);
4275 if (current && !current.promotedTo) discardComposerDraft(draftId);
4276 return;
4277 }
4278 }
4279 })();
4280 },
4281 [copyBranchToClipboard, copyPathToClipboard, openProjectSettings, projectByKey],
4282 );
4283
4230 const handleThreadContextMenu = useCallback(4284 const handleThreadContextMenu = useCallback(
4231 (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {4285 (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
4232 void (async () => {4286 void (async () => {
...@@ -5048,6 +5102,7 @@ export default function Sidebar() {...@@ -5048,6 +5102,7 @@ export default function Sidebar() {
5048 scopedProjectKeys={scopedProjectKeys}5102 scopedProjectKeys={scopedProjectKeys}
5049 routeDraftId={routeDraftIdForRows}5103 routeDraftId={routeDraftIdForRows}
5050 onNavigateToDraft={navigateToDraft}5104 onNavigateToDraft={navigateToDraft}
5105 onDraftContextMenu={handleDraftContextMenu}
5051 />,5106 />,
5052 ];5107 ];
5053 for (const item of sidebarListItems) {5108 for (const item of sidebarListItems) {
apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx+2-1
...@@ -12,11 +12,12 @@ export function SidebarThreadUndoNotice() {...@@ -12,11 +12,12 @@ export function SidebarThreadUndoNotice() {
1212
13 if (!notice) return null;13 if (!notice) return null;
14 const shortcut = shortcutLabelForCommand(keybindings, "thread.undo");14 const shortcut = shortcutLabelForCommand(keybindings, "thread.undo");
15 const noun = `${notice.action === "Discarded" ? "draft" : "thread"}${notice.count === 1 ? "" : "s"}`;
1516
16 return (17 return (
17 <Alert role="status" variant="sidebar">18 <Alert role="status" variant="sidebar">
18 <AlertDescription>19 <AlertDescription>
19 {notice.action} {notice.count} thread{notice.count === 1 ? "" : "s"},{" "}20 {notice.action} {notice.count} {noun},{" "}
20 <InlineButton onClick={undoLatestThreadAction}>21 <InlineButton onClick={undoLatestThreadAction}>
21 {shortcut ? `${shortcut} to undo` : "Undo"}22 {shortcut ? `${shortcut} to undo` : "Undo"}
22 </InlineButton>23 </InlineButton>
apps/web/src/components/threadActionMenu.logic.test.ts+26-1
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { buildThreadActionMenuItems, type ThreadActionMenuState } from "./threadActionMenu.logic";3import {
4 buildDraftActionMenuItems,
5 buildThreadActionMenuItems,
6 type ThreadActionMenuState,
7} from "./threadActionMenu.logic";
48
5const baseState: ThreadActionMenuState = {9const baseState: ThreadActionMenuState = {
6 branch: null,10 branch: null,
...@@ -167,3 +171,24 @@ describe("buildThreadActionMenuItems", () => {...@@ -167,3 +171,24 @@ describe("buildThreadActionMenuItems", () => {
167 expect(archiveItem?.disabled).toBe(true);171 expect(archiveItem?.disabled).toBe(true);
168 });172 });
169});173});
174
175describe("buildDraftActionMenuItems", () => {
176 it("offers only the copy values the draft has", () => {
177 const items = buildDraftActionMenuItems({ hasPath: false, hasBranch: true, hasProject: true });
178 expect(items[0]).toMatchObject({ id: "copy", disabled: false });
179 expect(items[0]?.children?.map((item) => item.id)).toEqual(["copy-branch"]);
180
181 const noCopy = buildDraftActionMenuItems({
182 hasPath: false,
183 hasBranch: false,
184 hasProject: true,
185 });
186 expect(noCopy[0]).toMatchObject({ id: "copy", disabled: true, children: [] });
187 });
188
189 it("drops project settings without a project and keeps discard last", () => {
190 const items = buildDraftActionMenuItems({ hasPath: true, hasBranch: false, hasProject: false });
191 expect(items.map((item) => item.id)).toEqual(["copy", "discard"]);
192 expect(items.at(-1)).toMatchObject({ label: "Discard draft", destructive: true });
193 });
194});
apps/web/src/components/threadActionMenu.logic.ts+39
...@@ -30,6 +30,45 @@ export type ThreadActionMenuId =...@@ -30,6 +30,45 @@ export type ThreadActionMenuId =
30 | "archive"30 | "archive"
31 | "delete";31 | "delete";
3232
33export type DraftActionMenuId =
34 | "copy"
35 | "copy-path"
36 | "copy-branch"
37 | "project-settings"
38 | "discard";
39
40/** Right-click menu for an unsent draft row in the sidebar. */
41export function buildDraftActionMenuItems(options: {
42 readonly hasPath: boolean;
43 readonly hasBranch: boolean;
44 readonly hasProject: boolean;
45}): ReadonlyArray<ContextMenuItem<DraftActionMenuId>> {
46 return [
47 {
48 id: "copy",
49 label: "Copy",
50 icon: "copy",
51 disabled: !options.hasPath && !options.hasBranch,
52 children: [
53 ...(options.hasPath ? [{ id: "copy-path" as const, label: "Path", icon: "folder" }] : []),
54 ...(options.hasBranch
55 ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }]
56 : []),
57 ],
58 },
59 ...(options.hasProject
60 ? [{ id: "project-settings" as const, label: "Project settings", icon: "settings" }]
61 : []),
62 {
63 id: "discard",
64 label: "Discard draft",
65 icon: "trash",
66 destructive: true,
67 separatorBefore: true,
68 },
69 ];
70}
71
33export interface ThreadActionMenuState {72export interface ThreadActionMenuState {
34 readonly branch: string | null;73 readonly branch: string | null;
35 /**74 /**
apps/web/src/composerDraftStore.ts+1-1
...@@ -1504,7 +1504,7 @@ function normalizeComposerTarget(...@@ -1504,7 +1504,7 @@ function normalizeComposerTarget(
1504 return target;1504 return target;
1505}1505}
15061506
1507function resolveComposerDraftKey(1507export function resolveComposerDraftKey(
1508 state: ComposerThreadLookupState,1508 state: ComposerThreadLookupState,
1509 target: ComposerThreadTarget,1509 target: ComposerThreadTarget,
1510): string | null {1510): string | null {
apps/web/src/hooks/showThreadUndoNotice.ts+10-3
...@@ -9,10 +9,12 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast";...@@ -9,10 +9,12 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast";
9import * as ThreadUndo from "./threadUndo";9import * as ThreadUndo from "./threadUndo";
1010
11type UndoOptions = {11type UndoOptions = {
12 action: "Settled" | "Snoozed" | "Unpinned" | "Archived";12 action: "Settled" | "Snoozed" | "Unpinned" | "Archived" | "Discarded";
13 undo: () => Promise<AtomCommandResult<unknown, unknown>>;13 undo: () => Promise<AtomCommandResult<unknown, unknown>>;
14 failureTitle: string;14 failureTitle: string;
15 claim: ReturnType<typeof ThreadUndo.begin>;15 claim: ReturnType<typeof ThreadUndo.begin>;
16 /** Runs once the action can no longer be undone. */
17 commit?: () => void;
16};18};
1719
18type UndoNotice = {20type UndoNotice = {
...@@ -29,7 +31,9 @@ let liveUndos: UndoOptions[] = [];...@@ -29,7 +31,9 @@ let liveUndos: UndoOptions[] = [];
29let expiry: ReturnType<typeof setTimeout> | undefined;31let expiry: ReturnType<typeof setTimeout> | undefined;
3032
31function refreshNotice() {33function refreshNotice() {
32 liveUndos = liveUndos.filter(({ claim }) => claim.isCurrent());34 const stale = liveUndos.filter(({ claim }) => !claim.isCurrent());
35 liveUndos = liveUndos.filter((entry) => !stale.includes(entry));
36 for (const entry of stale) entry.commit?.();
33 const latest = liveUndos.at(-1);37 const latest = liveUndos.at(-1);
34 if (!latest) {38 if (!latest) {
35 clearTimeout(expiry);39 clearTimeout(expiry);
...@@ -100,7 +104,10 @@ export function showThreadUndoNotice(options: UndoOptions) {...@@ -100,7 +104,10 @@ export function showThreadUndoNotice(options: UndoOptions) {
100 expiry = setTimeout(() => {104 expiry = setTimeout(() => {
101 const expired = liveUndos;105 const expired = liveUndos;
102 liveUndos = [];106 liveUndos = [];
103 for (const { claim } of expired) claim.finish();107 for (const { claim, commit } of expired) {
108 claim.finish();
109 commit?.();
110 }
104 refreshNotice();111 refreshNotice();
105 }, 5_000);112 }, 5_000);
106}113}
apps/web/src/lib/discardComposerDraft.test.ts created+89
...@@ -0,0 +1,89 @@
1import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
2import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { toastManager } from "../components/ui/toast";
6import { DraftId, useComposerDraftStore } from "../composerDraftStore";
7import { useThreadUndoNotice } from "../hooks/showThreadUndoNotice";
8import { releaseDraftAttachments } from "./attachmentUploadQueue";
9import { discardComposerDraft } from "./discardComposerDraft";
10
11vi.mock("./attachmentUploadQueue", () => ({ releaseDraftAttachments: vi.fn() }));
12
13const environmentId = EnvironmentId.make("environment-local");
14const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1"));
15const draftId = DraftId.make("draft-1");
16const threadRef = scopeThreadRef(environmentId, ThreadId.make("thread-1"));
17
18function undoNotice() {
19 const notice = useThreadUndoNotice.getState().notice;
20 if (!notice) throw new Error("Undo notice is missing");
21 return notice;
22}
23
24beforeEach(() => {
25 vi.useFakeTimers();
26 useComposerDraftStore.setState({
27 draftsByThreadKey: {},
28 draftThreadsByThreadKey: {},
29 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
30 });
31});
32afterEach(() => {
33 vi.runAllTimers();
34 vi.useRealTimers();
35 vi.clearAllMocks();
36 vi.restoreAllMocks();
37});
38
39describe("discardComposerDraft", () => {
40 it("restores a discarded new-thread draft with its project mapping", async () => {
41 const store = useComposerDraftStore.getState();
42 store.setProjectDraftThreadId(projectRef, draftId);
43 store.setPrompt(draftId, "half-written prompt");
44 const before = useComposerDraftStore.getState();
45
46 discardComposerDraft(draftId);
47 expect(useComposerDraftStore.getState().getDraftSession(draftId)).toBeNull();
48 expect(undoNotice()).toMatchObject({ action: "Discarded", count: 1 });
49
50 await undoNotice().undo();
51 const after = useComposerDraftStore.getState();
52 expect(after.getDraftSession(draftId)).toEqual(before.getDraftSession(draftId));
53 expect(after.getComposerDraft(draftId)?.prompt).toBe("half-written prompt");
54 expect(after.logicalProjectDraftThreadKeyByLogicalProjectKey).toEqual(
55 before.logicalProjectDraftThreadKeyByLogicalProjectKey,
56 );
57 vi.runAllTimers();
58 expect(releaseDraftAttachments).not.toHaveBeenCalled();
59 });
60
61 it("clears a thread draft for good and releases its uploads once undo expires", async () => {
62 useComposerDraftStore.getState().setPrompt(threadRef, "reply in progress");
63
64 discardComposerDraft(threadRef);
65 const notice = undoNotice();
66 expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt ?? "").toBe("");
67 expect(releaseDraftAttachments).not.toHaveBeenCalled();
68
69 vi.advanceTimersByTime(5_000);
70 expect(useThreadUndoNotice.getState().notice).toBeNull();
71 expect(releaseDraftAttachments).toHaveBeenCalledOnce();
72 await notice.undo();
73 expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt ?? "").toBe("");
74 });
75
76 it("keeps text typed after the discard and releases the old uploads", async () => {
77 useComposerDraftStore.getState().setPrompt(threadRef, "old reply");
78 discardComposerDraft(threadRef);
79 useComposerDraftStore.getState().setPrompt(threadRef, "new reply");
80 const addToast = vi.spyOn(toastManager, "add").mockReturnValue("error-toast");
81
82 await undoNotice().undo();
83 expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt).toBe("new reply");
84 expect(releaseDraftAttachments).toHaveBeenCalledOnce();
85 expect(addToast).toHaveBeenCalledWith(
86 expect.objectContaining({ title: "Failed to restore draft" }),
87 );
88 });
89});
apps/web/src/lib/discardComposerDraft.ts created+79
...@@ -0,0 +1,79 @@
1import * as Cause from "effect/Cause";
2import { AsyncResult } from "effect/unstable/reactivity";
3
4import {
5 type ComposerThreadTarget,
6 composerDraftHasUserContent,
7 resolveComposerDraftKey,
8 useComposerDraftStore,
9} from "../composerDraftStore";
10import { showThreadUndoNotice } from "../hooks/showThreadUndoNotice";
11import * as ThreadUndo from "../hooks/threadUndo";
12import { releaseDraftAttachments } from "./attachmentUploadQueue";
13
14/**
15 * Discards a draft's unsent content behind the sidebar undo notice. A new-thread
16 * draft (DraftId) loses its whole session; a thread draft only loses its
17 * composer content. Uploads are released once the undo window closes.
18 */
19export function discardComposerDraft(target: ComposerThreadTarget): void {
20 const store = useComposerDraftStore.getState();
21 const key = resolveComposerDraftKey(store, target);
22 const draft = key === null ? undefined : store.draftsByThreadKey[key];
23 if (key === null || !draft) return;
24 const session = store.draftThreadsByThreadKey[key];
25 const logicalProjectKeys = Object.entries(store.logicalProjectDraftThreadKeyByLogicalProjectKey)
26 .filter(([, draftKey]) => draftKey === key)
27 .map(([logicalProjectKey]) => logicalProjectKey);
28 const discardsSession = typeof target === "string";
29
30 const claim = ThreadUndo.begin("discard", key);
31 if (discardsSession) {
32 store.clearDraftThread(target);
33 } else {
34 store.clearComposerContent(target);
35 }
36
37 showThreadUndoNotice({
38 action: "Discarded",
39 claim,
40 failureTitle: "Failed to restore draft",
41 undo: async () => {
42 const current = useComposerDraftStore.getState().draftsByThreadKey[key];
43 if (current && composerDraftHasUserContent(current)) {
44 releaseDraftAttachments([...draft.images, ...draft.files]);
45 return AsyncResult.failure(Cause.fail(new Error("The draft has new content.")));
46 }
47 useComposerDraftStore.setState((state) => {
48 const logicalProjectDraftThreadKeyByLogicalProjectKey = {
49 ...state.logicalProjectDraftThreadKeyByLogicalProjectKey,
50 };
51 for (const logicalProjectKey of logicalProjectKeys) {
52 logicalProjectDraftThreadKeyByLogicalProjectKey[logicalProjectKey] ??= key;
53 }
54 return {
55 draftsByThreadKey: {
56 ...state.draftsByThreadKey,
57 // Removing a draft session revokes its image previews.
58 [key]: discardsSession
59 ? {
60 ...draft,
61 images: draft.images.map((image) =>
62 image.previewUrl.startsWith("blob:")
63 ? { ...image, previewUrl: URL.createObjectURL(image.file) }
64 : image,
65 ),
66 }
67 : draft,
68 },
69 draftThreadsByThreadKey: session
70 ? { ...state.draftThreadsByThreadKey, [key]: session }
71 : state.draftThreadsByThreadKey,
72 logicalProjectDraftThreadKeyByLogicalProjectKey,
73 };
74 });
75 return AsyncResult.success(undefined);
76 },
77 commit: () => releaseDraftAttachments([...draft.images, ...draft.files]),
78 });
79}
docs/user/keybindings.md+2-1
...@@ -120,7 +120,8 @@ a shortcut....@@ -120,7 +120,8 @@ a shortcut.
120shortcut; assign one in **Settings → Keybindings**.120shortcut; assign one in **Settings → Keybindings**.
121121
122`thread.undo` (`mod+z` by default) reverses the actions shown in the notice at the122`thread.undo` (`mod+z` by default) reverses the actions shown in the notice at the
123bottom of the sidebar, such as unpin, settle, snooze, or archive. Consecutive123bottom of the sidebar, such as unpin, settle, snooze, archive, or discarding a
124draft. Consecutive
124actions of the same kind undo together. The notice remains available for five125actions of the same kind undo together. The notice remains available for five
125seconds after the latest action. The default shortcut skips text fields and126seconds after the latest action. The default shortcut skips text fields and
126terminals so native undo keeps working there.127terminals so native undo keeps working there.
docs/user/thread-sidebar.md+2-1
...@@ -48,7 +48,8 @@ Pin a thread from its menu to keep it above your active work....@@ -48,7 +48,8 @@ Pin a thread from its menu to keep it above your active work.
48On web and desktop, unpinning, settling, snoozing, and archiving a thread each show48On web and desktop, unpinning, settling, snoozing, and archiving a thread each show
49a notification with **Undo** for five seconds. Undo restores the thread's previous49a notification with **Undo** for five seconds. Undo restores the thread's previous
50state, including its pinned position, and reopens an archived thread you were50state, including its pinned position, and reopens an archived thread you were
51viewing. `mod+z` triggers the most recent Undo when no text field is focused; see51viewing. Discarding an unsent draft from the sidebar works the same way: Undo brings
52back its text and attachments. `mod+z` triggers the most recent Undo when no text field is focused; see
52[Keybindings](./keybindings.md#commands-with-special-behavior).53[Keybindings](./keybindings.md#commands-with-special-behavior).
5354
54On web and desktop, you can also drag files from your computer onto any thread row:55On web and desktop, you can also drag files from your computer onto any thread row: