| author | |
| committer | |
| log | 95edeb753b070bad191e10afe15918c969135225 |
| tree | bbe22aedc24f90314d302671f307a11777c789b7 |
| parent | f90b77d80971ea4855a42abfc89ce85466e1accb |
| signature | Signed by PGP key B5690EEEBB952194 |
10 files changed, 326 insertions(+), 29 deletions(-)
apps/web/src/components/Sidebar.tsx+76-21| ... | ... | @@ -7,7 +7,7 @@ import { |
| 7 | 7 | endThreadContextDrag, |
| 8 | 8 | moveThreadContextDrag as moveThreadContextDragGhost, |
| 9 | 9 | } from "./chat/threadContextDrag"; |
| 10 | import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; | |
| 10 | import { discardComposerDraft } from "../lib/discardComposerDraft"; | |
| 11 | 11 | import { requestCustomSnooze } from "./CustomSnoozeDialog"; |
| 12 | 12 | import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; |
| 13 | 13 | import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; |
| ... | ... | @@ -169,7 +169,7 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 169 | 169 | import { cn } from "~/lib/utils"; |
| 170 | 170 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 171 | 171 | import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; |
| 172 | import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; | |
| 172 | import { buildDraftActionMenuItems, buildThreadActionMenuItems } from "./threadActionMenu.logic"; | |
| 173 | 173 | import { |
| 174 | 174 | animateSidebarLayoutChanges, |
| 175 | 175 | applySidebarThreadDrop, |
| ... | ... | @@ -798,8 +798,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { |
| 798 | 798 | isActive: boolean; |
| 799 | 799 | onNavigate: (draftId: DraftId) => void; |
| 800 | 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 | 804 | const promptPreview = |
| 804 | 805 | replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label) |
| 805 | 806 | .trim() |
| ... | ... | @@ -829,12 +830,23 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { |
| 829 | 830 | // preventDefault here would swallow Space's synthesized click and |
| 830 | 831 | // navigate instead of discarding. |
| 831 | 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 | 838 | event.preventDefault(); |
| 834 | 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 | 851 | const handleDiscard = useCallback( |
| 840 | 852 | (event: ReactMouseEvent) => { |
| ... | ... | @@ -857,6 +869,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { |
| 857 | 869 | props.isActive ? "bg-sidebar-row-active" : draftSurfaceClassName, |
| 858 | 870 | )} |
| 859 | 871 | onClick={handleActivate} |
| 872 | onContextMenu={handleContextMenu} | |
| 860 | 873 | onKeyDown={handleKeyDown} |
| 861 | 874 | > |
| 862 | 875 | <span className="sr-only">{preview}</span> |
| ... | ... | @@ -912,10 +925,10 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { |
| 912 | 925 | scopedProjectKeys: ReadonlySet<string> | null; |
| 913 | 926 | routeDraftId: string | null; |
| 914 | 927 | onNavigateToDraft: (draftId: DraftId) => void; |
| 928 | onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; | |
| 915 | 929 | }) { |
| 916 | 930 | const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); |
| 917 | 931 | const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey); |
| 918 | const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread); | |
| 919 | 932 | // The open draft's row is FROZEN at the moment the draft became the route: |
| 920 | 933 | // it stays visible (like a thread row) but never repaints while the user |
| 921 | 934 | // types. A draft that was never navigated away from has no snapshot to |
| ... | ... | @@ -979,16 +992,6 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { |
| 979 | 992 | props.routeDraftId, |
| 980 | 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 | 995 | if (drafts.length === 0) { |
| 993 | 996 | return null; |
| 994 | 997 | } |
| ... | ... | @@ -1005,7 +1008,11 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { |
| 1005 | 1008 | projectDisplayName={props.projectDisplayNameByKey.get(projectKey) ?? null} |
| 1006 | 1009 | isActive={draftId === props.routeDraftId} |
| 1007 | 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 | 1171 | // Unsent composer text on this thread. The open thread shows its own |
| 1165 | 1172 | // composer, so the marker only decorates rows you have navigated away from. |
| 1166 | 1173 | const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isActive; |
| 1167 | const clearComposerContent = useComposerDraftStore((store) => store.clearComposerContent); | |
| 1168 | 1174 | const handleDiscardDraftClick = useCallback( |
| 1169 | 1175 | (event: ReactMouseEvent) => { |
| 1170 | 1176 | event.preventDefault(); |
| 1171 | 1177 | event.stopPropagation(); |
| 1172 | releaseComposerDraftUploads(threadRef); | |
| 1173 | clearComposerContent(threadRef); | |
| 1178 | discardComposerDraft(threadRef); | |
| 1174 | 1179 | }, |
| 1175 | [clearComposerContent, threadRef], | |
| 1180 | [threadRef], | |
| 1176 | 1181 | ); |
| 1177 | 1182 | |
| 1178 | 1183 | const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null; |
| ... | ... | @@ -4227,6 +4232,55 @@ export default function Sidebar() { |
| 4227 | 4232 | ], |
| 4228 | 4233 | ); |
| 4229 | 4234 | |
| 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 | 4284 | const handleThreadContextMenu = useCallback( |
| 4231 | 4285 | (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { |
| 4232 | 4286 | void (async () => { |
| ... | ... | @@ -5048,6 +5102,7 @@ export default function Sidebar() { |
| 5048 | 5102 | scopedProjectKeys={scopedProjectKeys} |
| 5049 | 5103 | routeDraftId={routeDraftIdForRows} |
| 5050 | 5104 | onNavigateToDraft={navigateToDraft} |
| 5105 | onDraftContextMenu={handleDraftContextMenu} | |
| 5051 | 5106 | />, |
| 5052 | 5107 | ]; |
| 5053 | 5108 | for (const item of sidebarListItems) { |
apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx+2-1| ... | ... | @@ -12,11 +12,12 @@ export function SidebarThreadUndoNotice() { |
| 12 | 12 | |
| 13 | 13 | if (!notice) return null; |
| 14 | 14 | const shortcut = shortcutLabelForCommand(keybindings, "thread.undo"); |
| 15 | const noun = `${notice.action === "Discarded" ? "draft" : "thread"}${notice.count === 1 ? "" : "s"}`; | |
| 15 | 16 | |
| 16 | 17 | return ( |
| 17 | 18 | <Alert role="status" variant="sidebar"> |
| 18 | 19 | <AlertDescription> |
| 19 | {notice.action} {notice.count} thread{notice.count === 1 ? "" : "s"},{" "} | |
| 20 | {notice.action} {notice.count} {noun},{" "} | |
| 20 | 21 | <InlineButton onClick={undoLatestThreadAction}> |
| 21 | 22 | {shortcut ? `${shortcut} to undo` : "Undo"} |
| 22 | 23 | </InlineButton> |
apps/web/src/components/threadActionMenu.logic.test.ts+26-1| ... | ... | @@ -1,6 +1,10 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | import { buildThreadActionMenuItems, type ThreadActionMenuState } from "./threadActionMenu.logic"; | |
| 3 | import { | |
| 4 | buildDraftActionMenuItems, | |
| 5 | buildThreadActionMenuItems, | |
| 6 | type ThreadActionMenuState, | |
| 7 | } from "./threadActionMenu.logic"; | |
| 4 | 8 | |
| 5 | 9 | const baseState: ThreadActionMenuState = { |
| 6 | 10 | branch: null, |
| ... | ... | @@ -167,3 +171,24 @@ describe("buildThreadActionMenuItems", () => { |
| 167 | 171 | expect(archiveItem?.disabled).toBe(true); |
| 168 | 172 | }); |
| 169 | 173 | }); |
| 174 | ||
| 175 | describe("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 | 30 | | "archive" |
| 31 | 31 | | "delete"; |
| 32 | 32 | |
| 33 | export 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. */ | |
| 41 | export 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 | ||
| 33 | 72 | export interface ThreadActionMenuState { |
| 34 | 73 | readonly branch: string | null; |
| 35 | 74 | /** |
apps/web/src/composerDraftStore.ts+1-1| ... | ... | @@ -1504,7 +1504,7 @@ function normalizeComposerTarget( |
| 1504 | 1504 | return target; |
| 1505 | 1505 | } |
| 1506 | 1506 | |
| 1507 | function resolveComposerDraftKey( | |
| 1507 | export function resolveComposerDraftKey( | |
| 1508 | 1508 | state: ComposerThreadLookupState, |
| 1509 | 1509 | target: ComposerThreadTarget, |
| 1510 | 1510 | ): string | null { |
apps/web/src/hooks/showThreadUndoNotice.ts+10-3| ... | ... | @@ -9,10 +9,12 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast"; |
| 9 | 9 | import * as ThreadUndo from "./threadUndo"; |
| 10 | 10 | |
| 11 | 11 | type UndoOptions = { |
| 12 | action: "Settled" | "Snoozed" | "Unpinned" | "Archived"; | |
| 12 | action: "Settled" | "Snoozed" | "Unpinned" | "Archived" | "Discarded"; | |
| 13 | 13 | undo: () => Promise<AtomCommandResult<unknown, unknown>>; |
| 14 | 14 | failureTitle: string; |
| 15 | 15 | claim: ReturnType<typeof ThreadUndo.begin>; |
| 16 | /** Runs once the action can no longer be undone. */ | |
| 17 | commit?: () => void; | |
| 16 | 18 | }; |
| 17 | 19 | |
| 18 | 20 | type UndoNotice = { |
| ... | ... | @@ -29,7 +31,9 @@ let liveUndos: UndoOptions[] = []; |
| 29 | 31 | let expiry: ReturnType<typeof setTimeout> | undefined; |
| 30 | 32 | |
| 31 | 33 | function 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 | 37 | const latest = liveUndos.at(-1); |
| 34 | 38 | if (!latest) { |
| 35 | 39 | clearTimeout(expiry); |
| ... | ... | @@ -100,7 +104,10 @@ export function showThreadUndoNotice(options: UndoOptions) { |
| 100 | 104 | expiry = setTimeout(() => { |
| 101 | 105 | const expired = liveUndos; |
| 102 | 106 | liveUndos = []; |
| 103 | for (const { claim } of expired) claim.finish(); | |
| 107 | for (const { claim, commit } of expired) { | |
| 108 | claim.finish(); | |
| 109 | commit?.(); | |
| 110 | } | |
| 104 | 111 | refreshNotice(); |
| 105 | 112 | }, 5_000); |
| 106 | 113 | } |
apps/web/src/lib/discardComposerDraft.test.ts created+89| ... | ... | @@ -0,0 +1,89 @@ |
| 1 | import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { toastManager } from "../components/ui/toast"; | |
| 6 | import { DraftId, useComposerDraftStore } from "../composerDraftStore"; | |
| 7 | import { useThreadUndoNotice } from "../hooks/showThreadUndoNotice"; | |
| 8 | import { releaseDraftAttachments } from "./attachmentUploadQueue"; | |
| 9 | import { discardComposerDraft } from "./discardComposerDraft"; | |
| 10 | ||
| 11 | vi.mock("./attachmentUploadQueue", () => ({ releaseDraftAttachments: vi.fn() })); | |
| 12 | ||
| 13 | const environmentId = EnvironmentId.make("environment-local"); | |
| 14 | const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1")); | |
| 15 | const draftId = DraftId.make("draft-1"); | |
| 16 | const threadRef = scopeThreadRef(environmentId, ThreadId.make("thread-1")); | |
| 17 | ||
| 18 | function undoNotice() { | |
| 19 | const notice = useThreadUndoNotice.getState().notice; | |
| 20 | if (!notice) throw new Error("Undo notice is missing"); | |
| 21 | return notice; | |
| 22 | } | |
| 23 | ||
| 24 | beforeEach(() => { | |
| 25 | vi.useFakeTimers(); | |
| 26 | useComposerDraftStore.setState({ | |
| 27 | draftsByThreadKey: {}, | |
| 28 | draftThreadsByThreadKey: {}, | |
| 29 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 30 | }); | |
| 31 | }); | |
| 32 | afterEach(() => { | |
| 33 | vi.runAllTimers(); | |
| 34 | vi.useRealTimers(); | |
| 35 | vi.clearAllMocks(); | |
| 36 | vi.restoreAllMocks(); | |
| 37 | }); | |
| 38 | ||
| 39 | describe("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 @@ |
| 1 | import * as Cause from "effect/Cause"; | |
| 2 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 3 | ||
| 4 | import { | |
| 5 | type ComposerThreadTarget, | |
| 6 | composerDraftHasUserContent, | |
| 7 | resolveComposerDraftKey, | |
| 8 | useComposerDraftStore, | |
| 9 | } from "../composerDraftStore"; | |
| 10 | import { showThreadUndoNotice } from "../hooks/showThreadUndoNotice"; | |
| 11 | import * as ThreadUndo from "../hooks/threadUndo"; | |
| 12 | import { 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 | */ | |
| 19 | export 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 | 120 | shortcut; assign one in **Settings → Keybindings**. |
| 121 | 121 | |
| 122 | 122 | `thread.undo` (`mod+z` by default) reverses the actions shown in the notice at the |
| 123 | bottom of the sidebar, such as unpin, settle, snooze, or archive. Consecutive | |
| 123 | bottom of the sidebar, such as unpin, settle, snooze, archive, or discarding a | |
| 124 | draft. Consecutive | |
| 124 | 125 | actions of the same kind undo together. The notice remains available for five |
| 125 | 126 | seconds after the latest action. The default shortcut skips text fields and |
| 126 | 127 | terminals 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 | 48 | On web and desktop, unpinning, settling, snoozing, and archiving a thread each show |
| 49 | 49 | a notification with **Undo** for five seconds. Undo restores the thread's previous |
| 50 | 50 | state, including its pinned position, and reopens an archived thread you were |
| 51 | viewing. `mod+z` triggers the most recent Undo when no text field is focused; see | |
| 51 | viewing. Discarding an unsent draft from the sidebar works the same way: Undo brings | |
| 52 | back its text and attachments. `mod+z` triggers the most recent Undo when no text field is focused; see | |
| 52 | 53 | [Keybindings](./keybindings.md#commands-with-special-behavior). |
| 53 | 54 | |
| 54 | 55 | On web and desktop, you can also drag files from your computer onto any thread row: |