From 65b005f1e4bfccb6a404b3b1e5bfa363d534ac2a Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:32:02 +0530 Subject: [PATCH] feat(web): add Copy Thread ID to the sidebar and chat header thread context menu (#5574) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- apps/web/src/components/Sidebar.tsx | 24 ++++++++++++++++++- .../components/threadActionMenu.logic.test.ts | 2 +- .../src/components/threadActionMenu.logic.ts | 2 ++ apps/web/src/hooks/useThreadActionMenu.ts | 12 +++++++++- 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 41e964a7e1b62ed0783cc7d0a35d8f006099b9b3..840e4918bfa76b17ee4b1a04f8045e238d748167 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -29,7 +29,7 @@ import { scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import type { TimestampFormat } from "@t3tools/contracts/settings"; import { AlarmClockIcon, @@ -1647,6 +1647,24 @@ export default function Sidebar() { ); }, }); + const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({ + onCopy: ({ threadId }) => { + toastManager.add({ + type: "success", + title: "Thread ID copied", + description: threadId, + }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to copy thread ID", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + }); const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false); const newThreadContext = useHandleNewThread(); const openAddProjectCommandPalette = useCallback( @@ -3037,6 +3055,9 @@ export default function Sidebar() { copyBranchToClipboard(thread.branch, { branch: thread.branch }); } return; + case "copy-thread-id": + copyThreadIdToClipboard(thread.id, { threadId: thread.id }); + return; case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -3079,6 +3100,7 @@ export default function Sidebar() { confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, + copyThreadIdToClipboard, deleteThread, handleMultiSelectContextMenu, markThreadUnread, diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index a450b29f2266bddfd191d48651590ca85d9b7613..93dc653e7c0ad5c35445ec3fe014b2110dee7264 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -26,7 +26,7 @@ describe("buildThreadActionMenuItems", () => { ...baseState, supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, }), - ).toEqual(["rename", "mark-unread", "copy-path", "delete"]); + ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]); }); it("includes branch items only for threads with a branch", () => { diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 66aaf3debf54134181241abe5a7d1c24383e7ce6..ef4b38dcdacdbb3e15943b8a38bc60c37611d27e 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -20,6 +20,7 @@ export type ThreadActionMenuId = | "mark-unread" | "copy-path" | "copy-branch" + | "copy-thread-id" | "delete"; export interface ThreadActionMenuState { @@ -100,6 +101,7 @@ export function buildThreadActionMenuItems( { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy path", icon: "copy" }, ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []), + { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ]; } diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 24efe4ea1963f229214bbb12b8e99c38a4074b78..4eac13fddb308894cb71bcaadcbc32fe73eb9ac5 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -11,7 +11,7 @@ import { effectiveSnoozed, type ChangeRequestStateLike, } from "@t3tools/client-runtime/state/thread-settled"; -import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { useCallback } from "react"; import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze"; @@ -95,6 +95,12 @@ export function useThreadActionMenu(input: { }, onError: (error) => failureToast("Failed to copy branch", error), }); + const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({ + onCopy: ({ threadId }) => { + toastManager.add({ type: "success", title: "Thread ID copied", description: threadId }); + }, + onError: (error) => failureToast("Failed to copy thread ID", error), + }); const openMenu = useCallback( (position: { x: number; y: number }) => { @@ -242,6 +248,9 @@ export function useThreadActionMenu(input: { copyBranchToClipboard(thread.branch, { branch: thread.branch }); } return; + case "copy-thread-id": + copyThreadIdToClipboard(thread.id, { threadId: thread.id }); + return; case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -279,6 +288,7 @@ export function useThreadActionMenu(input: { confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, + copyThreadIdToClipboard, deleteThread, handleNewThread, markThreadUnread, -- 2.54.0