authorgravatar for 105842516+jamesx0416@users.noreply.github.comJames <105842516+jamesx0416@users.noreply.github.com> 2026-03-17 16:34:24+11:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-16 22:34:24-07:00
logd430a0624b570561a4d8eedc7c277fb6c31c46e1
tree8c03798daf26270dfec3c319b33c8f6f7ccfd950
parent765c1dc9e418f4ce0dbeb42c154d7ecb65a09fb6
signature Signed by PGP key B5690EEEBB952194

Add copy workspace path button to thread action menu (#1128)


1 files changed, 44 insertions(+), 6 deletions(-)

apps/web/src/components/Sidebar.tsx+44-6
...@@ -561,7 +561,6 @@ export default function Sidebar() {...@@ -561,7 +561,6 @@ export default function Sidebar() {
561 if (!api) return;561 if (!api) return;
562 const thread = threads.find((t) => t.id === threadId);562 const thread = threads.find((t) => t.id === threadId);
563 if (!thread) return;563 if (!thread) return;
564
565 const threadProject = projects.find((project) => project.id === thread.projectId);564 const threadProject = projects.find((project) => project.id === thread.projectId);
566 // When bulk-deleting, exclude the other threads being deleted so565 // When bulk-deleting, exclude the other threads being deleted so
567 // getOrphanedWorktreePathForThread correctly detects that no surviving566 // getOrphanedWorktreePathForThread correctly detects that no surviving
...@@ -665,7 +664,7 @@ export default function Sidebar() {...@@ -665,7 +664,7 @@ export default function Sidebar() {
665 ],664 ],
666 );665 );
667666
668 const { copyToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({667 const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({
669 onCopy: (ctx) => {668 onCopy: (ctx) => {
670 toastManager.add({669 toastManager.add({
671 type: "success",670 type: "success",
...@@ -681,21 +680,40 @@ export default function Sidebar() {...@@ -681,21 +680,40 @@ export default function Sidebar() {
681 });680 });
682 },681 },
683 });682 });
683 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
684 onCopy: (ctx) => {
685 toastManager.add({
686 type: "success",
687 title: "Path copied",
688 description: ctx.path,
689 });
690 },
691 onError: (error) => {
692 toastManager.add({
693 type: "error",
694 title: "Failed to copy path",
695 description: error instanceof Error ? error.message : "An error occurred.",
696 });
697 },
698 });
684 const handleThreadContextMenu = useCallback(699 const handleThreadContextMenu = useCallback(
685 async (threadId: ThreadId, position: { x: number; y: number }) => {700 async (threadId: ThreadId, position: { x: number; y: number }) => {
686 const api = readNativeApi();701 const api = readNativeApi();
687 if (!api) return;702 if (!api) return;
703 const thread = threads.find((t) => t.id === threadId);
704 if (!thread) return;
705 const threadWorkspacePath =
706 thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null;
688 const clicked = await api.contextMenu.show(707 const clicked = await api.contextMenu.show(
689 [708 [
690 { id: "rename", label: "Rename thread" },709 { id: "rename", label: "Rename thread" },
691 { id: "mark-unread", label: "Mark unread" },710 { id: "mark-unread", label: "Mark unread" },
711 { id: "copy-path", label: "Copy Path" },
692 { id: "copy-thread-id", label: "Copy Thread ID" },712 { id: "copy-thread-id", label: "Copy Thread ID" },
693 { id: "delete", label: "Delete", destructive: true },713 { id: "delete", label: "Delete", destructive: true },
694 ],714 ],
695 position,715 position,
696 );716 );
697 const thread = threads.find((t) => t.id === threadId);
698 if (!thread) return;
699717
700 if (clicked === "rename") {718 if (clicked === "rename") {
701 setRenamingThreadId(threadId);719 setRenamingThreadId(threadId);
...@@ -708,8 +726,20 @@ export default function Sidebar() {...@@ -708,8 +726,20 @@ export default function Sidebar() {
708 markThreadUnread(threadId);726 markThreadUnread(threadId);
709 return;727 return;
710 }728 }
729 if (clicked === "copy-path") {
730 if (!threadWorkspacePath) {
731 toastManager.add({
732 type: "error",
733 title: "Path unavailable",
734 description: "This thread does not have a workspace path to copy.",
735 });
736 return;
737 }
738 copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath });
739 return;
740 }
711 if (clicked === "copy-thread-id") {741 if (clicked === "copy-thread-id") {
712 copyToClipboard(threadId, { threadId });742 copyThreadIdToClipboard(threadId, { threadId });
713 return;743 return;
714 }744 }
715 if (clicked !== "delete") return;745 if (clicked !== "delete") return;
...@@ -726,7 +756,15 @@ export default function Sidebar() {...@@ -726,7 +756,15 @@ export default function Sidebar() {
726 }756 }
727 await deleteThread(threadId);757 await deleteThread(threadId);
728 },758 },
729 [appSettings.confirmThreadDelete, copyToClipboard, deleteThread, markThreadUnread, threads],759 [
760 appSettings.confirmThreadDelete,
761 copyPathToClipboard,
762 copyThreadIdToClipboard,
763 deleteThread,
764 markThreadUnread,
765 projectCwdById,
766 threads,
767 ],
730 );768 );
731769
732 const handleMultiSelectContextMenu = useCallback(770 const handleMultiSelectContextMenu = useCallback(