From 3122490f9d1c32f03c9ea5c8ed88267c9341d234 Mon Sep 17 00:00:00 2001 From: flamboh Date: Sun, 4 Oct 2026 01:55:10 +0000 Subject: [PATCH 1/2] fix(web): discarding a sidebar draft can be undone Fixes #12735 --- apps/web/src/components/Sidebar.tsx | 24 ++--- .../sidebar/SidebarThreadUndoNotice.tsx | 3 +- apps/web/src/composerDraftStore.ts | 2 +- apps/web/src/hooks/showThreadUndoNotice.ts | 13 ++- apps/web/src/lib/discardComposerDraft.test.ts | 89 +++++++++++++++++++ apps/web/src/lib/discardComposerDraft.ts | 79 ++++++++++++++++ docs/user/keybindings.md | 3 +- docs/user/thread-sidebar.md | 3 +- 8 files changed, 192 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/lib/discardComposerDraft.test.ts create mode 100644 apps/web/src/lib/discardComposerDraft.ts diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ffea669cc1b7..d4c11a077c49 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"; @@ -915,7 +915,6 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { }) { const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey); - const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread); // The open draft's row is FROZEN at the moment the draft became the route: // it stays visible (like a thread row) but never repaints while the user // types. A draft that was never navigated away from has no snapshot to @@ -979,16 +978,6 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { props.routeDraftId, props.scopedProjectKeys, ]); - const handleDiscard = useCallback( - (draftId: DraftId) => { - // The /draft/$draftId route redirects home on its own when the draft - // it renders disappears, so discarding the open draft needs no - // special-casing here. - releaseComposerDraftUploads(draftId); - clearDraftThread(draftId); - }, - [clearDraftThread], - ); if (drafts.length === 0) { return null; } @@ -1005,7 +994,10 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { projectDisplayName={props.projectDisplayNameByKey.get(projectKey) ?? null} isActive={draftId === props.routeDraftId} onNavigate={props.onNavigateToDraft} - onDiscard={handleDiscard} + // The /draft/$draftId route redirects home on its own when the + // draft it renders disappears, so discarding the open draft needs + // no special-casing here. + onDiscard={discardComposerDraft} /> ); })} @@ -1164,15 +1156,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // Unsent composer text on this thread. The open thread shows its own // composer, so the marker only decorates rows you have navigated away from. const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isActive; - const clearComposerContent = useComposerDraftStore((store) => store.clearComposerContent); const handleDiscardDraftClick = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); - releaseComposerDraftUploads(threadRef); - clearComposerContent(threadRef); + discardComposerDraft(threadRef); }, - [clearComposerContent, threadRef], + [threadRef], ); const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null; 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/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: From 347f1dbc3a2de842cf816eb3b71faefc3082ebce Mon Sep 17 00:00:00 2001 From: flamboh Date: Sun, 4 Oct 2026 01:47:22 +0000 Subject: [PATCH 2/2] fix(web): add context menu to draft threads in the sidebar --- apps/web/src/components/Sidebar.tsx | 73 ++++++++++++++++++- .../components/threadActionMenu.logic.test.ts | 27 ++++++- .../src/components/threadActionMenu.logic.ts | 39 ++++++++++ 3 files changed, 134 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d4c11a077c49..716f4cad9cc7 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -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,6 +925,7 @@ 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); @@ -998,6 +1012,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { // draft it renders disappears, so discarding the open draft needs // no special-casing here. onDiscard={discardComposerDraft} + onContextMenu={props.onDraftContextMenu} /> ); })} @@ -4217,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 () => { @@ -5038,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/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; /**