diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 22c28df1ce66..28c5e3ae3341 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -28,7 +28,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, @@ -1631,6 +1631,25 @@ export default function Sidebar() { ); }, }); + const { copyToClipboard: copyThreadId } = useCopyToClipboard<{ threadId: ThreadId }>({ + target: "thread ID", + 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( @@ -3007,6 +3026,9 @@ export default function Sidebar() { copyBranchToClipboard(thread.branch, { branch: thread.branch }); } return; + case "copy-thread-id": + copyThreadId(thread.id, { threadId: thread.id }); + return; case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -3049,6 +3071,7 @@ export default function Sidebar() { confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, + copyThreadId, deleteThread, handleMultiSelectContextMenu, markThreadUnread, diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index a450b29f2266..93dc653e7c0a 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 66aaf3debf54..ef4b38dcdacd 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 24efe4ea1963..316a609fe19e 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,13 @@ export function useThreadActionMenu(input: { }, onError: (error) => failureToast("Failed to copy branch", error), }); + const { copyToClipboard: copyThreadId } = useCopyToClipboard<{ threadId: ThreadId }>({ + target: "thread ID", + 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 +249,9 @@ export function useThreadActionMenu(input: { copyBranchToClipboard(thread.branch, { branch: thread.branch }); } return; + case "copy-thread-id": + copyThreadId(threadRef.threadId, { threadId: threadRef.threadId }); + return; case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -279,6 +289,7 @@ export function useThreadActionMenu(input: { confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, + copyThreadId, deleteThread, handleNewThread, markThreadUnread,