diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ffea669cc1b7..716f4cad9cc7 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -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"; @@ -169,7 +169,7 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; -import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; +import { buildDraftActionMenuItems, buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { animateSidebarLayoutChanges, applySidebarThreadDrop, @@ -798,8 +798,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { isActive: boolean; onNavigate: (draftId: DraftId) => void; onDiscard: (draftId: DraftId) => void; + onContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; }) { - const { composer, draftId, onDiscard, onNavigate } = props; + const { composer, draftId, onContextMenu, onDiscard, onNavigate } = props; const promptPreview = replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label) .trim() @@ -829,12 +830,23 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { // preventDefault here would swallow Space's synthesized click and // navigate instead of discarding. if ((event.target as HTMLElement).closest("button")) return; - if (event.key === "Enter" || event.key === " ") { + if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) { + event.preventDefault(); + const rect = event.currentTarget.getBoundingClientRect(); + onContextMenu(draftId, { x: rect.left, y: rect.bottom }); + } else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onNavigate(draftId); } }, - [draftId, onNavigate], + [draftId, onContextMenu, onNavigate], + ); + const handleContextMenu = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + onContextMenu(draftId, { x: event.clientX, y: event.clientY }); + }, + [draftId, onContextMenu], ); const handleDiscard = useCallback( (event: ReactMouseEvent) => { @@ -857,6 +869,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { props.isActive ? "bg-sidebar-row-active" : draftSurfaceClassName, )} onClick={handleActivate} + onContextMenu={handleContextMenu} onKeyDown={handleKeyDown} > {preview} @@ -912,10 +925,10 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { scopedProjectKeys: ReadonlySet | null; routeDraftId: string | null; onNavigateToDraft: (draftId: DraftId) => void; + onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; }) { 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 @@ -979,16 +992,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; } @@ -1005,7 +1008,11 @@ 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} + onContextMenu={props.onDraftContextMenu} /> ); })} @@ -1164,15 +1171,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); }, - [clearComposerContent, threadRef], + [threadRef], ); const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null; @@ -4227,6 +4232,55 @@ export default function Sidebar() { ], ); + const handleDraftContextMenu = useCallback( + (draftId: DraftId, position: { x: number; y: number }) => { + void (async () => { + const api = readLocalApi(); + const session = useComposerDraftStore.getState().getDraftSession(draftId); + if (!api || !session || session.promotedTo) return; + const projectGroup = projectGroupsRef.current.find((group) => + group.memberProjectRefs.some( + (ref) => + ref.environmentId === session.environmentId && ref.projectId === session.projectId, + ), + ); + const workspacePath = + session.worktreePath ?? + projectByKey.get(`${session.environmentId}:${session.projectId}`)?.workspaceRoot; + const clicked = await settlePromise(() => + api.contextMenu.show( + buildDraftActionMenuItems({ + hasPath: Boolean(workspacePath), + hasBranch: Boolean(session.branch), + hasProject: projectGroup != null, + }), + position, + ), + ); + if (clicked._tag === "Failure") return; + switch (clicked.value) { + case "project-settings": + if (projectGroup) openProjectSettings(projectGroup); + return; + case "copy-path": + if (workspacePath) copyPathToClipboard(workspacePath, { path: workspacePath }); + return; + case "copy-branch": + if (session.branch) copyBranchToClipboard(session.branch, { branch: session.branch }); + return; + case "discard": { + // The menu can stay open while the draft sends; discarding a + // promoting draft would strand the send. + const current = useComposerDraftStore.getState().getDraftSession(draftId); + if (current && !current.promotedTo) discardComposerDraft(draftId); + return; + } + } + })(); + }, + [copyBranchToClipboard, copyPathToClipboard, openProjectSettings, projectByKey], + ); + const handleThreadContextMenu = useCallback( (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { void (async () => { @@ -5048,6 +5102,7 @@ export default function Sidebar() { scopedProjectKeys={scopedProjectKeys} routeDraftId={routeDraftIdForRows} onNavigateToDraft={navigateToDraft} + onDraftContextMenu={handleDraftContextMenu} />, ]; for (const item of sidebarListItems) { diff --git a/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx b/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx index 8f26a62c89bf..7f3f5bfe77ec 100644 --- a/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx @@ -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 ( - {notice.action} {notice.count} thread{notice.count === 1 ? "" : "s"},{" "} + {notice.action} {notice.count} {noun},{" "} {shortcut ? `${shortcut} to undo` : "Undo"} diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index c5ebeda55627..1f85eb1a4e2e 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildThreadActionMenuItems, type ThreadActionMenuState } from "./threadActionMenu.logic"; +import { + buildDraftActionMenuItems, + buildThreadActionMenuItems, + type ThreadActionMenuState, +} from "./threadActionMenu.logic"; const baseState: ThreadActionMenuState = { branch: null, @@ -167,3 +171,24 @@ describe("buildThreadActionMenuItems", () => { expect(archiveItem?.disabled).toBe(true); }); }); + +describe("buildDraftActionMenuItems", () => { + it("offers only the copy values the draft has", () => { + const items = buildDraftActionMenuItems({ hasPath: false, hasBranch: true, hasProject: true }); + expect(items[0]).toMatchObject({ id: "copy", disabled: false }); + expect(items[0]?.children?.map((item) => item.id)).toEqual(["copy-branch"]); + + const noCopy = buildDraftActionMenuItems({ + hasPath: false, + hasBranch: false, + hasProject: true, + }); + expect(noCopy[0]).toMatchObject({ id: "copy", disabled: true, children: [] }); + }); + + it("drops project settings without a project and keeps discard last", () => { + const items = buildDraftActionMenuItems({ hasPath: true, hasBranch: false, hasProject: false }); + expect(items.map((item) => item.id)).toEqual(["copy", "discard"]); + expect(items.at(-1)).toMatchObject({ label: "Discard draft", destructive: true }); + }); +}); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index a1ed9c277a4f..1b9ada254f14 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -30,6 +30,45 @@ export type ThreadActionMenuId = | "archive" | "delete"; +export type DraftActionMenuId = + | "copy" + | "copy-path" + | "copy-branch" + | "project-settings" + | "discard"; + +/** Right-click menu for an unsent draft row in the sidebar. */ +export function buildDraftActionMenuItems(options: { + readonly hasPath: boolean; + readonly hasBranch: boolean; + readonly hasProject: boolean; +}): ReadonlyArray> { + return [ + { + id: "copy", + label: "Copy", + icon: "copy", + disabled: !options.hasPath && !options.hasBranch, + children: [ + ...(options.hasPath ? [{ id: "copy-path" as const, label: "Path", icon: "folder" }] : []), + ...(options.hasBranch + ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }] + : []), + ], + }, + ...(options.hasProject + ? [{ id: "project-settings" as const, label: "Project settings", icon: "settings" }] + : []), + { + id: "discard", + label: "Discard draft", + icon: "trash", + destructive: true, + separatorBefore: true, + }, + ]; +} + export interface ThreadActionMenuState { readonly branch: string | null; /** diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index f3ad5f9aecf9..5e0e76730b9b 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1504,7 +1504,7 @@ function normalizeComposerTarget( return target; } -function resolveComposerDraftKey( +export function resolveComposerDraftKey( state: ComposerThreadLookupState, target: ComposerThreadTarget, ): string | null { diff --git a/apps/web/src/hooks/showThreadUndoNotice.ts b/apps/web/src/hooks/showThreadUndoNotice.ts index ea296219487a..c8608136c3fa 100644 --- a/apps/web/src/hooks/showThreadUndoNotice.ts +++ b/apps/web/src/hooks/showThreadUndoNotice.ts @@ -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>; failureTitle: string; claim: ReturnType; + /** Runs once the action can no longer be undone. */ + commit?: () => void; }; type UndoNotice = { @@ -29,7 +31,9 @@ let liveUndos: UndoOptions[] = []; let expiry: ReturnType | 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); @@ -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); } diff --git a/apps/web/src/lib/discardComposerDraft.test.ts b/apps/web/src/lib/discardComposerDraft.test.ts new file mode 100644 index 000000000000..7f40b4ec874e --- /dev/null +++ b/apps/web/src/lib/discardComposerDraft.test.ts @@ -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" }), + ); + }); +}); diff --git a/apps/web/src/lib/discardComposerDraft.ts b/apps/web/src/lib/discardComposerDraft.ts new file mode 100644 index 000000000000..1a6ffbff6133 --- /dev/null +++ b/apps/web/src/lib/discardComposerDraft.ts @@ -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."))); + } + 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]), + }); +} diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index e305b30510e1..9f1ef0626fdd 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -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. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index a43896189d71..38c5caaa8255 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -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: