| 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 +7,7 @@ import { |
| 7 | endThreadContextDrag, | 7 | endThreadContextDrag, |
| 8 | moveThreadContextDrag as moveThreadContextDragGhost, | 8 | moveThreadContextDrag as moveThreadContextDragGhost, |
| 9 | } from "./chat/threadContextDrag"; | 9 | } from "./chat/threadContextDrag"; |
| 10 | import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; | 10 | import { discardComposerDraft } from "../lib/discardComposerDraft"; |
| 11 | import { requestCustomSnooze } from "./CustomSnoozeDialog"; | 11 | import { requestCustomSnooze } from "./CustomSnoozeDialog"; |
| 12 | import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; | 12 | import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; |
| 13 | import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; | 13 | import { 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"; |
| 169 | import { cn } from "~/lib/utils"; | 169 | import { cn } from "~/lib/utils"; |
| 170 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; | 170 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 171 | import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; | 171 | import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; |
| 172 | import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; | 172 | import { buildDraftActionMenuItems, buildThreadActionMenuItems } from "./threadActionMenu.logic"; |
| 173 | import { | 173 | import { |
| 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 and | 830 | // 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 user | 933 | // 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 to | 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,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 own | 1171 | // 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 | ); |
| 1177 | 1182 | ||
| 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 | ); |
| 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 | 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() { |
| 12 | 12 | ||
| 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"}`; | ||
| 15 | 16 | ||
| 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 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 | const baseState: ThreadActionMenuState = { | 9 | const 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 | |||
| 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,6 +30,45 @@ export type ThreadActionMenuId = |
| 30 | | "archive" | 30 | | "archive" |
| 31 | | "delete"; | 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 | export interface ThreadActionMenuState { | 72 | export 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 | } |
| 1506 | 1506 | ||
| 1507 | function resolveComposerDraftKey( | 1507 | export 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"; |
| 9 | import * as ThreadUndo from "./threadUndo"; | 9 | import * as ThreadUndo from "./threadUndo"; |
| 10 | 10 | ||
| 11 | type UndoOptions = { | 11 | type 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 | }; |
| 17 | 19 | ||
| 18 | type UndoNotice = { | 20 | type UndoNotice = { |
| ... | @@ -29,7 +31,9 @@ let liveUndos: UndoOptions[] = []; | ... | @@ -29,7 +31,9 @@ let liveUndos: UndoOptions[] = []; |
| 29 | let expiry: ReturnType<typeof setTimeout> | undefined; | 31 | let expiry: ReturnType<typeof setTimeout> | undefined; |
| 30 | 32 | ||
| 31 | function refreshNotice() { | 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 | 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 @@ | ||
| 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,7 +120,8 @@ a shortcut. |
| 120 | shortcut; assign one in **Settings → Keybindings**. | 120 | shortcut; assign one in **Settings → Keybindings**. |
| 121 | 121 | ||
| 122 | `thread.undo` (`mod+z` by default) reverses the actions shown in the notice at the | 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 | actions of the same kind undo together. The notice remains available for five | 125 | actions of the same kind undo together. The notice remains available for five |
| 125 | seconds after the latest action. The default shortcut skips text fields and | 126 | seconds after the latest action. The default shortcut skips text fields and |
| 126 | terminals so native undo keeps working there. | 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,7 +48,8 @@ Pin a thread from its menu to keep it above your active work. |
| 48 | On web and desktop, unpinning, settling, snoozing, and archiving a thread each show | 48 | On web and desktop, unpinning, settling, snoozing, and archiving a thread each show |
| 49 | a notification with **Undo** for five seconds. Undo restores the thread's previous | 49 | a notification with **Undo** for five seconds. Undo restores the thread's previous |
| 50 | state, including its pinned position, and reopens an archived thread you were | 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 | [Keybindings](./keybindings.md#commands-with-special-behavior). | 53 | [Keybindings](./keybindings.md#commands-with-special-behavior). |
| 53 | 54 | ||
| 54 | On web and desktop, you can also drag files from your computer onto any thread row: | 55 | On web and desktop, you can also drag files from your computer onto any thread row: |