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

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


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

apps/web/src/components/Sidebar.tsx+76-21
......@@ -7,7 +7,7 @@ import {
77 endThreadContextDrag,
88 moveThreadContextDrag as moveThreadContextDragGhost,
99} from "./chat/threadContextDrag";
10import { releaseComposerDraftUploads } from "../lib/composerDraftUploads";
10import { discardComposerDraft } from "../lib/discardComposerDraft";
1111import { requestCustomSnooze } from "./CustomSnoozeDialog";
1212import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
1313import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
......@@ -169,7 +169,7 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
169169import { cn } from "~/lib/utils";
170170import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
171171import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge";
172import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
172import { buildDraftActionMenuItems, buildThreadActionMenuItems } from "./threadActionMenu.logic";
173173import {
174174 animateSidebarLayoutChanges,
175175 applySidebarThreadDrop,
......@@ -798,8 +798,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
798798 isActive: boolean;
799799 onNavigate: (draftId: DraftId) => void;
800800 onDiscard: (draftId: DraftId) => void;
801 onContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void;
801802}) {
802 const { composer, draftId, onDiscard, onNavigate } = props;
803 const { composer, draftId, onContextMenu, onDiscard, onNavigate } = props;
803804 const promptPreview =
804805 replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label)
805806 .trim()
......@@ -829,12 +830,23 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
829830 // preventDefault here would swallow Space's synthesized click and
830831 // navigate instead of discarding.
831832 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 === " ") {
833838 event.preventDefault();
834839 onNavigate(draftId);
835840 }
836841 },
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],
838850 );
839851 const handleDiscard = useCallback(
840852 (event: ReactMouseEvent) => {
......@@ -857,6 +869,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
857869 props.isActive ? "bg-sidebar-row-active" : draftSurfaceClassName,
858870 )}
859871 onClick={handleActivate}
872 onContextMenu={handleContextMenu}
860873 onKeyDown={handleKeyDown}
861874 >
862875 <span className="sr-only">{preview}</span>
......@@ -912,10 +925,10 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
912925 scopedProjectKeys: ReadonlySet<string> | null;
913926 routeDraftId: string | null;
914927 onNavigateToDraft: (draftId: DraftId) => void;
928 onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void;
915929}) {
916930 const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey);
917931 const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey);
918 const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);
919932 // The open draft's row is FROZEN at the moment the draft became the route:
920933 // it stays visible (like a thread row) but never repaints while the user
921934 // types. A draft that was never navigated away from has no snapshot to
......@@ -979,16 +992,6 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
979992 props.routeDraftId,
980993 props.scopedProjectKeys,
981994 ]);
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 );
992995 if (drafts.length === 0) {
993996 return null;
994997 }
......@@ -1005,7 +1008,11 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
10051008 projectDisplayName={props.projectDisplayNameByKey.get(projectKey) ?? null}
10061009 isActive={draftId === props.routeDraftId}
10071010 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}
10091016 />
10101017 );
10111018 })}
......@@ -1164,15 +1171,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
11641171 // Unsent composer text on this thread. The open thread shows its own
11651172 // composer, so the marker only decorates rows you have navigated away from.
11661173 const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isActive;
1167 const clearComposerContent = useComposerDraftStore((store) => store.clearComposerContent);
11681174 const handleDiscardDraftClick = useCallback(
11691175 (event: ReactMouseEvent) => {
11701176 event.preventDefault();
11711177 event.stopPropagation();
1172 releaseComposerDraftUploads(threadRef);
1173 clearComposerContent(threadRef);
1178 discardComposerDraft(threadRef);
11741179 },
1175 [clearComposerContent, threadRef],
1180 [threadRef],
11761181 );
11771182
11781183 const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null;
......@@ -4227,6 +4232,55 @@ export default function Sidebar() {
42274232 ],
42284233 );
42294234
4235 const handleDraftContextMenu = useCallback(
4236 (draftId: DraftId, position: { x: number; y: number }) => {
4237 void (async () => {
4238 const api = readLocalApi();
4239 const session = useComposerDraftStore.getState().getDraftSession(draftId);
4240 if (!api || !session || session.promotedTo) return;
4241 const projectGroup = projectGroupsRef.current.find((group) =>
4242 group.memberProjectRefs.some(
4243 (ref) =>
4244 ref.environmentId === session.environmentId && ref.projectId === session.projectId,
4245 ),
4246 );
4247 const workspacePath =
4248 session.worktreePath ??
4249 projectByKey.get(`${session.environmentId}:${session.projectId}`)?.workspaceRoot;
4250 const clicked = await settlePromise(() =>
4251 api.contextMenu.show(
4252 buildDraftActionMenuItems({
4253 hasPath: Boolean(workspacePath),
4254 hasBranch: Boolean(session.branch),
4255 hasProject: projectGroup != null,
4256 }),
4257 position,
4258 ),
4259 );
4260 if (clicked._tag === "Failure") return;
4261 switch (clicked.value) {
4262 case "project-settings":
4263 if (projectGroup) openProjectSettings(projectGroup);
4264 return;
4265 case "copy-path":
4266 if (workspacePath) copyPathToClipboard(workspacePath, { path: workspacePath });
4267 return;
4268 case "copy-branch":
4269 if (session.branch) copyBranchToClipboard(session.branch, { branch: session.branch });
4270 return;
4271 case "discard": {
4272 // The menu can stay open while the draft sends; discarding a
4273 // promoting draft would strand the send.
4274 const current = useComposerDraftStore.getState().getDraftSession(draftId);
4275 if (current && !current.promotedTo) discardComposerDraft(draftId);
4276 return;
4277 }
4278 }
4279 })();
4280 },
4281 [copyBranchToClipboard, copyPathToClipboard, openProjectSettings, projectByKey],
4282 );
4283
42304284 const handleThreadContextMenu = useCallback(
42314285 (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
42324286 void (async () => {
......@@ -5048,6 +5102,7 @@ export default function Sidebar() {
50485102 scopedProjectKeys={scopedProjectKeys}
50495103 routeDraftId={routeDraftIdForRows}
50505104 onNavigateToDraft={navigateToDraft}
5105 onDraftContextMenu={handleDraftContextMenu}
50515106 />,
50525107 ];
50535108 for (const item of sidebarListItems) {
apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx+2-1
......@@ -12,11 +12,12 @@ export function SidebarThreadUndoNotice() {
1212
1313 if (!notice) return null;
1414 const shortcut = shortcutLabelForCommand(keybindings, "thread.undo");
15 const noun = `${notice.action === "Discarded" ? "draft" : "thread"}${notice.count === 1 ? "" : "s"}`;
1516
1617 return (
1718 <Alert role="status" variant="sidebar">
1819 <AlertDescription>
19 {notice.action} {notice.count} thread{notice.count === 1 ? "" : "s"},{" "}
20 {notice.action} {notice.count} {noun},{" "}
2021 <InlineButton onClick={undoLatestThreadAction}>
2122 {shortcut ? `${shortcut} to undo` : "Undo"}
2223 </InlineButton>
apps/web/src/components/threadActionMenu.logic.test.ts+26-1
......@@ -1,6 +1,10 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { buildThreadActionMenuItems, type ThreadActionMenuState } from "./threadActionMenu.logic";
3import {
4 buildDraftActionMenuItems,
5 buildThreadActionMenuItems,
6 type ThreadActionMenuState,
7} from "./threadActionMenu.logic";
48
59const baseState: ThreadActionMenuState = {
610 branch: null,
......@@ -167,3 +171,24 @@ describe("buildThreadActionMenuItems", () => {
167171 expect(archiveItem?.disabled).toBe(true);
168172 });
169173});
174
175describe("buildDraftActionMenuItems", () => {
176 it("offers only the copy values the draft has", () => {
177 const items = buildDraftActionMenuItems({ hasPath: false, hasBranch: true, hasProject: true });
178 expect(items[0]).toMatchObject({ id: "copy", disabled: false });
179 expect(items[0]?.children?.map((item) => item.id)).toEqual(["copy-branch"]);
180
181 const noCopy = buildDraftActionMenuItems({
182 hasPath: false,
183 hasBranch: false,
184 hasProject: true,
185 });
186 expect(noCopy[0]).toMatchObject({ id: "copy", disabled: true, children: [] });
187 });
188
189 it("drops project settings without a project and keeps discard last", () => {
190 const items = buildDraftActionMenuItems({ hasPath: true, hasBranch: false, hasProject: false });
191 expect(items.map((item) => item.id)).toEqual(["copy", "discard"]);
192 expect(items.at(-1)).toMatchObject({ label: "Discard draft", destructive: true });
193 });
194});
apps/web/src/components/threadActionMenu.logic.ts+39
......@@ -30,6 +30,45 @@ export type ThreadActionMenuId =
3030 | "archive"
3131 | "delete";
3232
33export type DraftActionMenuId =
34 | "copy"
35 | "copy-path"
36 | "copy-branch"
37 | "project-settings"
38 | "discard";
39
40/** Right-click menu for an unsent draft row in the sidebar. */
41export function buildDraftActionMenuItems(options: {
42 readonly hasPath: boolean;
43 readonly hasBranch: boolean;
44 readonly hasProject: boolean;
45}): ReadonlyArray<ContextMenuItem<DraftActionMenuId>> {
46 return [
47 {
48 id: "copy",
49 label: "Copy",
50 icon: "copy",
51 disabled: !options.hasPath && !options.hasBranch,
52 children: [
53 ...(options.hasPath ? [{ id: "copy-path" as const, label: "Path", icon: "folder" }] : []),
54 ...(options.hasBranch
55 ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }]
56 : []),
57 ],
58 },
59 ...(options.hasProject
60 ? [{ id: "project-settings" as const, label: "Project settings", icon: "settings" }]
61 : []),
62 {
63 id: "discard",
64 label: "Discard draft",
65 icon: "trash",
66 destructive: true,
67 separatorBefore: true,
68 },
69 ];
70}
71
3372export interface ThreadActionMenuState {
3473 readonly branch: string | null;
3574 /**
apps/web/src/composerDraftStore.ts+1-1
......@@ -1504,7 +1504,7 @@ function normalizeComposerTarget(
15041504 return target;
15051505}
15061506
1507function resolveComposerDraftKey(
1507export function resolveComposerDraftKey(
15081508 state: ComposerThreadLookupState,
15091509 target: ComposerThreadTarget,
15101510): string | null {
apps/web/src/hooks/showThreadUndoNotice.ts+10-3
......@@ -9,10 +9,12 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast";
99import * as ThreadUndo from "./threadUndo";
1010
1111type UndoOptions = {
12 action: "Settled" | "Snoozed" | "Unpinned" | "Archived";
12 action: "Settled" | "Snoozed" | "Unpinned" | "Archived" | "Discarded";
1313 undo: () => Promise<AtomCommandResult<unknown, unknown>>;
1414 failureTitle: string;
1515 claim: ReturnType<typeof ThreadUndo.begin>;
16 /** Runs once the action can no longer be undone. */
17 commit?: () => void;
1618};
1719
1820type UndoNotice = {
......@@ -29,7 +31,9 @@ let liveUndos: UndoOptions[] = [];
2931let expiry: ReturnType<typeof setTimeout> | undefined;
3032
3133function 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?.();
3337 const latest = liveUndos.at(-1);
3438 if (!latest) {
3539 clearTimeout(expiry);
......@@ -100,7 +104,10 @@ export function showThreadUndoNotice(options: UndoOptions) {
100104 expiry = setTimeout(() => {
101105 const expired = liveUndos;
102106 liveUndos = [];
103 for (const { claim } of expired) claim.finish();
107 for (const { claim, commit } of expired) {
108 claim.finish();
109 commit?.();
110 }
104111 refreshNotice();
105112 }, 5_000);
106113}
apps/web/src/lib/discardComposerDraft.test.ts created+89
......@@ -0,0 +1,89 @@
1import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
2import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { toastManager } from "../components/ui/toast";
6import { DraftId, useComposerDraftStore } from "../composerDraftStore";
7import { useThreadUndoNotice } from "../hooks/showThreadUndoNotice";
8import { releaseDraftAttachments } from "./attachmentUploadQueue";
9import { discardComposerDraft } from "./discardComposerDraft";
10
11vi.mock("./attachmentUploadQueue", () => ({ releaseDraftAttachments: vi.fn() }));
12
13const environmentId = EnvironmentId.make("environment-local");
14const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1"));
15const draftId = DraftId.make("draft-1");
16const threadRef = scopeThreadRef(environmentId, ThreadId.make("thread-1"));
17
18function undoNotice() {
19 const notice = useThreadUndoNotice.getState().notice;
20 if (!notice) throw new Error("Undo notice is missing");
21 return notice;
22}
23
24beforeEach(() => {
25 vi.useFakeTimers();
26 useComposerDraftStore.setState({
27 draftsByThreadKey: {},
28 draftThreadsByThreadKey: {},
29 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
30 });
31});
32afterEach(() => {
33 vi.runAllTimers();
34 vi.useRealTimers();
35 vi.clearAllMocks();
36 vi.restoreAllMocks();
37});
38
39describe("discardComposerDraft", () => {
40 it("restores a discarded new-thread draft with its project mapping", async () => {
41 const store = useComposerDraftStore.getState();
42 store.setProjectDraftThreadId(projectRef, draftId);
43 store.setPrompt(draftId, "half-written prompt");
44 const before = useComposerDraftStore.getState();
45
46 discardComposerDraft(draftId);
47 expect(useComposerDraftStore.getState().getDraftSession(draftId)).toBeNull();
48 expect(undoNotice()).toMatchObject({ action: "Discarded", count: 1 });
49
50 await undoNotice().undo();
51 const after = useComposerDraftStore.getState();
52 expect(after.getDraftSession(draftId)).toEqual(before.getDraftSession(draftId));
53 expect(after.getComposerDraft(draftId)?.prompt).toBe("half-written prompt");
54 expect(after.logicalProjectDraftThreadKeyByLogicalProjectKey).toEqual(
55 before.logicalProjectDraftThreadKeyByLogicalProjectKey,
56 );
57 vi.runAllTimers();
58 expect(releaseDraftAttachments).not.toHaveBeenCalled();
59 });
60
61 it("clears a thread draft for good and releases its uploads once undo expires", async () => {
62 useComposerDraftStore.getState().setPrompt(threadRef, "reply in progress");
63
64 discardComposerDraft(threadRef);
65 const notice = undoNotice();
66 expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt ?? "").toBe("");
67 expect(releaseDraftAttachments).not.toHaveBeenCalled();
68
69 vi.advanceTimersByTime(5_000);
70 expect(useThreadUndoNotice.getState().notice).toBeNull();
71 expect(releaseDraftAttachments).toHaveBeenCalledOnce();
72 await notice.undo();
73 expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt ?? "").toBe("");
74 });
75
76 it("keeps text typed after the discard and releases the old uploads", async () => {
77 useComposerDraftStore.getState().setPrompt(threadRef, "old reply");
78 discardComposerDraft(threadRef);
79 useComposerDraftStore.getState().setPrompt(threadRef, "new reply");
80 const addToast = vi.spyOn(toastManager, "add").mockReturnValue("error-toast");
81
82 await undoNotice().undo();
83 expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt).toBe("new reply");
84 expect(releaseDraftAttachments).toHaveBeenCalledOnce();
85 expect(addToast).toHaveBeenCalledWith(
86 expect.objectContaining({ title: "Failed to restore draft" }),
87 );
88 });
89});
apps/web/src/lib/discardComposerDraft.ts created+79
......@@ -0,0 +1,79 @@
1import * as Cause from "effect/Cause";
2import { AsyncResult } from "effect/unstable/reactivity";
3
4import {
5 type ComposerThreadTarget,
6 composerDraftHasUserContent,
7 resolveComposerDraftKey,
8 useComposerDraftStore,
9} from "../composerDraftStore";
10import { showThreadUndoNotice } from "../hooks/showThreadUndoNotice";
11import * as ThreadUndo from "../hooks/threadUndo";
12import { releaseDraftAttachments } from "./attachmentUploadQueue";
13
14/**
15 * Discards a draft's unsent content behind the sidebar undo notice. A new-thread
16 * draft (DraftId) loses its whole session; a thread draft only loses its
17 * composer content. Uploads are released once the undo window closes.
18 */
19export function discardComposerDraft(target: ComposerThreadTarget): void {
20 const store = useComposerDraftStore.getState();
21 const key = resolveComposerDraftKey(store, target);
22 const draft = key === null ? undefined : store.draftsByThreadKey[key];
23 if (key === null || !draft) return;
24 const session = store.draftThreadsByThreadKey[key];
25 const logicalProjectKeys = Object.entries(store.logicalProjectDraftThreadKeyByLogicalProjectKey)
26 .filter(([, draftKey]) => draftKey === key)
27 .map(([logicalProjectKey]) => logicalProjectKey);
28 const discardsSession = typeof target === "string";
29
30 const claim = ThreadUndo.begin("discard", key);
31 if (discardsSession) {
32 store.clearDraftThread(target);
33 } else {
34 store.clearComposerContent(target);
35 }
36
37 showThreadUndoNotice({
38 action: "Discarded",
39 claim,
40 failureTitle: "Failed to restore draft",
41 undo: async () => {
42 const current = useComposerDraftStore.getState().draftsByThreadKey[key];
43 if (current && composerDraftHasUserContent(current)) {
44 releaseDraftAttachments([...draft.images, ...draft.files]);
45 return AsyncResult.failure(Cause.fail(new Error("The draft has new content.")));
46 }
47 useComposerDraftStore.setState((state) => {
48 const logicalProjectDraftThreadKeyByLogicalProjectKey = {
49 ...state.logicalProjectDraftThreadKeyByLogicalProjectKey,
50 };
51 for (const logicalProjectKey of logicalProjectKeys) {
52 logicalProjectDraftThreadKeyByLogicalProjectKey[logicalProjectKey] ??= key;
53 }
54 return {
55 draftsByThreadKey: {
56 ...state.draftsByThreadKey,
57 // Removing a draft session revokes its image previews.
58 [key]: discardsSession
59 ? {
60 ...draft,
61 images: draft.images.map((image) =>
62 image.previewUrl.startsWith("blob:")
63 ? { ...image, previewUrl: URL.createObjectURL(image.file) }
64 : image,
65 ),
66 }
67 : draft,
68 },
69 draftThreadsByThreadKey: session
70 ? { ...state.draftThreadsByThreadKey, [key]: session }
71 : state.draftThreadsByThreadKey,
72 logicalProjectDraftThreadKeyByLogicalProjectKey,
73 };
74 });
75 return AsyncResult.success(undefined);
76 },
77 commit: () => releaseDraftAttachments([...draft.images, ...draft.files]),
78 });
79}
docs/user/keybindings.md+2-1
......@@ -120,7 +120,8 @@ a shortcut.
120120shortcut; assign one in **Settings → Keybindings**.
121121
122122`thread.undo` (`mod+z` by default) reverses the actions shown in the notice at the
123bottom of the sidebar, such as unpin, settle, snooze, or archive. Consecutive
123bottom of the sidebar, such as unpin, settle, snooze, archive, or discarding a
124draft. Consecutive
124125actions of the same kind undo together. The notice remains available for five
125126seconds after the latest action. The default shortcut skips text fields and
126127terminals 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.
4848On web and desktop, unpinning, settling, snoozing, and archiving a thread each show
4949a notification with **Undo** for five seconds. Undo restores the thread's previous
5050state, including its pinned position, and reopens an archived thread you were
51viewing. `mod+z` triggers the most recent Undo when no text field is focused; see
51viewing. Discarding an unsent draft from the sidebar works the same way: Undo brings
52back its text and attachments. `mod+z` triggers the most recent Undo when no text field is focused; see
5253[Keybindings](./keybindings.md#commands-with-special-behavior).
5354
5455On web and desktop, you can also drag files from your computer onto any thread row: