diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css
index 79178b8e..c1419a61 100644
--- a/engine/app/assets/stylesheets/coplan/application.css
+++ b/engine/app/assets/stylesheets/coplan/application.css
@@ -1709,6 +1709,10 @@ img.avatar {
margin-bottom: var(--space-sm);
}
+.thread-popover__permalink {
+ margin-left: auto;
+}
+
.thread-popover__quote {
border-left: 3px solid var(--color-quote-info-border);
padding: var(--space-xs) var(--space-sm);
diff --git a/engine/app/javascript/controllers/coplan/text_selection_controller.js b/engine/app/javascript/controllers/coplan/text_selection_controller.js
index 20e70929..6296abc4 100644
--- a/engine/app/javascript/controllers/coplan/text_selection_controller.js
+++ b/engine/app/javascript/controllers/coplan/text_selection_controller.js
@@ -215,6 +215,23 @@ export default class extends Controller {
this._showThreadPopoverFor(event.currentTarget, "pinned")
}
+ async copyThreadLink(event) {
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
+
+ event.preventDefault()
+ const link = event.currentTarget
+ const label = link.querySelector("[data-copy-label]")
+
+ try {
+ await navigator.clipboard.writeText(link.href)
+ label.textContent = "Copied!"
+ setTimeout(() => { label.textContent = "Copy link" }, 2000)
+ } catch {
+ label.textContent = "Copy failed"
+ setTimeout(() => { label.textContent = "Copy link" }, 2000)
+ }
+ }
+
// Internal: open the popover for a given mark in either "hover" or "pinned" mode.
// Returns true if the popover was shown, false otherwise.
_showThreadPopoverFor(trigger, mode) {
diff --git a/engine/app/views/coplan/comment_threads/_thread_popover.html.erb b/engine/app/views/coplan/comment_threads/_thread_popover.html.erb
index bfb86919..e0031c2d 100644
--- a/engine/app/views/coplan/comment_threads/_thread_popover.html.erb
+++ b/engine/app/views/coplan/comment_threads/_thread_popover.html.erb
@@ -13,6 +13,13 @@
<% if thread.out_of_date? %>
out of date
<% end %>
+ <%= link_to plan_path(plan, thread: thread.id),
+ class: "thread-popover__permalink btn btn--secondary btn--sm",
+ title: "Copy link to this comment",
+ data: { action: "coplan--text-selection#copyThreadLink" } do %>
+ 🔗
+ Copy link
+ <% end %>
<% if thread.anchored? %>
diff --git a/spec/system/comment_ux_spec.rb b/spec/system/comment_ux_spec.rb
index a29edcb1..c89e7ed8 100644
--- a/spec/system/comment_ux_spec.rb
+++ b/spec/system/comment_ux_spec.rb
@@ -139,6 +139,20 @@ def create_anchored_thread(plan:, anchor_text:, body:, user:)
expect(page).to have_content("Why not monolith?")
end
+ it "opens a linked comment and exposes its permalink" do
+ thread = create_anchored_thread(plan: plan, anchor_text: "microservices architecture", body: "Why not monolith?", user: reviewer)
+
+ visit plan_path(plan, thread: thread.id)
+
+ expect(page).to have_css(".thread-popover", visible: true)
+ within(".thread-popover") do
+ expect(page).to have_link("Copy link", href: plan_path(plan, thread: thread.id))
+ expect(page).to have_content("Why not monolith?")
+ click_link "Copy link"
+ expect(page).to have_link("Copied!")
+ end
+ end
+
it "shows reply form for open threads" do
create_anchored_thread(plan: plan, anchor_text: "microservices architecture", body: "Why not monolith?", user: reviewer)
visit plan_path(plan)