From 60848279828a00e0b95a931e6e5a09eccbf6a194 Mon Sep 17 00:00:00 2001 From: flamboh Date: Tue, 8 Sep 2026 09:32:18 +0000 Subject: [PATCH] feat(web): add PR Link to thread copy menu --- apps/web/src/components/Sidebar.tsx | 23 +++++++++++++++++++ .../components/threadActionMenu.logic.test.ts | 14 +++++++++++ .../src/components/threadActionMenu.logic.ts | 3 +++ apps/web/src/contextMenuFallback.ts | 10 ++++++++ apps/web/src/hooks/useThreadActionMenu.ts | 15 ++++++++++++ 5 files changed, 65 insertions(+) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 674a8c3ef8f5..4fa027db2785 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2134,6 +2134,21 @@ export default function Sidebar() { ); }, }); + const { copyToClipboard: copyPrLinkToClipboard } = useCopyToClipboard<{ url: string }>({ + target: "PR link", + onCopy: ({ url }) => { + toastManager.add({ type: "success", title: "PR link copied", description: url }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to copy PR link", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + }); const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({ onCopy: ({ threadId }) => { toastManager.add({ @@ -3909,12 +3924,14 @@ export default function Sidebar() { const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); const isPinned = thread.pinnedAt != null; + const prUrl = (thread.linkedPullRequest ?? thread.branchPullRequest)?.url ?? null; // Presets resolve at menu-open time (same as the popover). const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); const clicked = await settlePromise(() => api.contextMenu.show( buildThreadActionMenuItems({ branch: thread.branch ?? null, + prUrl, isPinned, isSettled, isSnoozed, @@ -4033,6 +4050,11 @@ export default function Sidebar() { copyBranchToClipboard(thread.branch, { branch: thread.branch }); } return; + case "copy-pr-link": + if (prUrl) { + copyPrLinkToClipboard(prUrl, { url: prUrl }); + } + return; case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; @@ -4108,6 +4130,7 @@ export default function Sidebar() { confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, + copyPrLinkToClipboard, copyThreadIdToClipboard, deleteThread, handleMultiSelectContextMenu, diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 1bdd04693759..ece9e121e928 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -4,6 +4,7 @@ import { buildThreadActionMenuItems, type ThreadActionMenuState } from "./thread const baseState: ThreadActionMenuState = { branch: null, + prUrl: null, isPinned: false, isSettled: false, isSnoozed: false, @@ -55,6 +56,19 @@ describe("buildThreadActionMenuItems", () => { expect(allIds(baseState)).not.toContain("copy-branch"); }); + it("offers PR Link in Copy only when a PR URL is available", () => { + const copy = buildThreadActionMenuItems({ + ...baseState, + prUrl: "https://github.com/pingdotgg/t3code/pull/8531", + }).find((item) => item.id === "copy"); + expect(copy?.children).toContainEqual({ + id: "copy-pr-link", + label: "PR Link", + icon: "link", + }); + expect(allIds(baseState)).not.toContain("copy-pr-link"); + }); + it("flips lifecycle labels with thread state", () => { expect(ids({ ...baseState, isPinned: true, isSettled: true, isSnoozed: true })).toEqual( expect.arrayContaining(["unpin", "unsettle", "unsnooze"]), diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 5ba266f7709d..64014a380c4b 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -21,6 +21,7 @@ export type ThreadActionMenuId = | "mark-unread" | "copy" | "copy-path" + | "copy-pr-link" | "copy-branch" | "copy-thread-id" | "archive" @@ -28,6 +29,7 @@ export type ThreadActionMenuId = export interface ThreadActionMenuState { readonly branch: string | null; + readonly prUrl: string | null; readonly isPinned: boolean; readonly isSettled: boolean; readonly isSnoozed: boolean; @@ -117,6 +119,7 @@ export function buildThreadActionMenuItems( ...(state.branch ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }] : []), + ...(state.prUrl ? [{ id: "copy-pr-link" as const, label: "PR Link", icon: "link" }] : []), { id: "copy-thread-id", label: "Thread ID", icon: "hash" }, ], }, diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 2c43641b2b56..377cb296380a 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -51,6 +51,16 @@ const ICON_PATHS: Record failureToast("Failed to copy branch", error), }); + const { copyToClipboard: copyPrLinkToClipboard } = useCopyToClipboard<{ url: string }>({ + target: "PR link", + onCopy: ({ url }) => { + toastManager.add({ type: "success", title: "PR link copied", description: url }); + }, + onError: (error) => failureToast("Failed to copy PR link", error), + }); const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({ onCopy: ({ threadId }) => { toastManager.add({ type: "success", title: "Thread ID copied", description: threadId }); @@ -136,9 +143,11 @@ export function useThreadActionMenu(input: { titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId), }; const isRegeneratingTitle = thread.titleRegeneration != null; + const prUrl = (thread.linkedPullRequest ?? thread.branchPullRequest)?.url ?? null; const snoozePresets = resolveSnoozePresets(now, timestampFormat); const items = buildThreadActionMenuItems({ branch: thread.branch ?? null, + prUrl, isPinned: thread.pinnedAt != null, isSettled: supports.settlement && thread.settledOverride === "settled", isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), @@ -273,6 +282,11 @@ export function useThreadActionMenu(input: { copyBranchToClipboard(thread.branch, { branch: thread.branch }); } return; + case "copy-pr-link": + if (prUrl) { + copyPrLinkToClipboard(prUrl, { url: prUrl }); + } + return; case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; @@ -335,6 +349,7 @@ export function useThreadActionMenu(input: { confirmAndUnpinThread, copyBranchToClipboard, copyPathToClipboard, + copyPrLinkToClipboard, copyThreadIdToClipboard, deleteThread, handleNewThread,