diff --git a/src/Client/CanvasPane.fs b/src/Client/CanvasPane.fs
index eaf4aa3..f818cc0 100644
--- a/src/Client/CanvasPane.fs
+++ b/src/Client/CanvasPane.fs
@@ -330,6 +330,7 @@ let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus
prop.className (if isActive then "canvas-iframe canvas-iframe-active" else "canvas-iframe")
prop.src (iframeSrc wt d)
prop.custom ("sandbox", "allow-scripts allow-same-origin allow-forms allow-popups")
+ prop.custom ("allow", "clipboard-write")
prop.style [
if not isActive then style.display.none
]
diff --git a/src/Server/BeadspaceTemplate.html b/src/Server/BeadspaceTemplate.html
index 0038a4d..9b85fb5 100644
--- a/src/Server/BeadspaceTemplate.html
+++ b/src/Server/BeadspaceTemplate.html
@@ -183,8 +183,36 @@
font-size: 0.6875rem;
color: var(--text-muted);
white-space: nowrap;
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
}
+.copy-id-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.25rem;
+ height: 1.25rem;
+ padding: 0;
+ background: transparent;
+ border: none;
+ border-radius: 4px;
+ color: var(--text-muted);
+ cursor: pointer;
+ opacity: 0;
+ flex-shrink: 0;
+ transition: opacity 0.12s ease, color 0.12s ease, background 0.12s ease;
+}
+.issues-table tr.issue-table-row:hover .copy-id-btn { opacity: 1; }
+.copy-id-btn:hover { color: var(--text-primary); background: var(--bg-elevated); }
+.copy-id-btn:focus-visible { opacity: 1; outline: 1px solid var(--accent); outline-offset: 1px; }
+.copy-id-btn svg { width: 0.875rem; height: 0.875rem; display: block; }
+.copy-id-btn .check-icon { display: none; }
+.copy-id-btn.copied { color: var(--status-closed); opacity: 1; }
+.copy-id-btn.copied .copy-icon { display: none; }
+.copy-id-btn.copied .check-icon { display: block; }
+
.issues-table .col-title {
font-weight: 500;
max-width: 400px;
@@ -407,6 +435,8 @@
margin-bottom: 0.125rem;
}
+ .copy-id-btn { opacity: 1; }
+
.issues-table .col-title {
white-space: normal;
max-width: none;
@@ -439,6 +469,32 @@
return el.innerHTML;
}
+function copyTextToClipboard(text) {
+ var safe = String(text).replace(/[\u0000-\u001F\u007F]/g, '');
+ return navigator.clipboard.writeText(safe);
+}
+
+var COPY_ICON_SVG = '';
+var CHECK_ICON_SVG = '';
+
+function createCopyIdButton(id) {
+ var btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'copy-id-btn';
+ btn.title = 'Copy ID';
+ btn.setAttribute('aria-label', 'Copy issue ID');
+ btn.innerHTML = COPY_ICON_SVG + CHECK_ICON_SVG;
+ btn.addEventListener('click', function(e) {
+ e.stopPropagation();
+ copyTextToClipboard(id).then(function() {
+ btn.classList.add('copied');
+ btn.title = 'Copied!';
+ setTimeout(function() { btn.classList.remove('copied'); btn.title = 'Copy ID'; }, 1200);
+ });
+ });
+ return btn;
+}
+
function daysAgo(dateStr) {
if (!dateStr) return 0;
var diff = Date.now() - new Date(dateStr).getTime();
@@ -628,7 +684,11 @@
var tdId = document.createElement('td');
tdId.className = 'col-id';
- tdId.textContent = i.id;
+ var idText = document.createElement('span');
+ idText.className = 'id-text';
+ idText.textContent = i.id;
+ tdId.appendChild(idText);
+ tdId.appendChild(createCopyIdButton(i.id));
tr.appendChild(tdId);
var tdPri = document.createElement('td');
diff --git a/src/Tests/BeadspaceCanvasTests.fs b/src/Tests/BeadspaceCanvasTests.fs
index 5686256..49d4783 100644
--- a/src/Tests/BeadspaceCanvasTests.fs
+++ b/src/Tests/BeadspaceCanvasTests.fs
@@ -307,3 +307,57 @@ type BeadspaceCanvasTests() =
let! tableCount = this.Page.Locator(".issues-table").CountAsync()
Assert.That(tableCount, Is.EqualTo(1), "Issues table should still be rendered after refresh")
}
+
+ // ── Copy ID Button ───────────────────────────────────────────────────
+
+ []
+ member this.``Each issue row has a copy-ID button``() =
+ task {
+ let allChip = this.Page.Locator(".filter-chip", PageLocatorOptions(HasText = "All"))
+ do! allChip.ClickAsync()
+ let! rowCount = this.Page.Locator(".issue-table-row").CountAsync()
+ let! btnCount = this.Page.Locator(".issue-table-row .copy-id-btn").CountAsync()
+ Assert.That(rowCount, Is.GreaterThan(0), "There should be issue rows to test")
+ Assert.That(btnCount, Is.EqualTo(rowCount), "Every issue row should have exactly one copy-ID button")
+ }
+
+ []
+ member this.``Clicking copy button copies ID without expanding detail panel``() =
+ task {
+ do! this.Context.GrantPermissionsAsync([| "clipboard-read"; "clipboard-write" |])
+
+ let allChip = this.Page.Locator(".filter-chip", PageLocatorOptions(HasText = "All"))
+ do! allChip.ClickAsync()
+
+ let firstRow = this.Page.Locator(".issue-table-row").First
+ let! expectedId = firstRow.GetAttributeAsync("data-issue-id")
+
+ let copyBtn = firstRow.Locator(".copy-id-btn")
+ do! copyBtn.ClickAsync()
+
+ // Detail panel must NOT expand — the button stops propagation
+ let! expandedCount = this.Page.Locator(".detail-panel.expanded").CountAsync()
+ Assert.That(expandedCount, Is.EqualTo(0), "Clicking the copy button should not expand the detail panel")
+
+ // Web-first assertion: auto-retries and passes on first match, so the page's
+ // 1200ms timer clearing the .copied class can't fail an already-satisfied check.
+ let copiedBtn = firstRow.Locator(".copy-id-btn.copied")
+ do! Assertions.Expect(copiedBtn).ToBeVisibleAsync(LocatorAssertionsToBeVisibleOptions(Timeout = 3000.0f))
+
+ // Best-effort: clipboard content matches the row id
+ let! clip = this.Page.EvaluateAsync("() => navigator.clipboard.readText()")
+ Assert.That(clip, Is.EqualTo(expectedId), "Clipboard should contain the copied issue ID")
+ }
+
+ []
+ member this.``Clicking row body still expands detail panel``() =
+ task {
+ let allChip = this.Page.Locator(".filter-chip", PageLocatorOptions(HasText = "All"))
+ do! allChip.ClickAsync()
+ let firstRow = this.Page.Locator(".issue-table-row").First
+ do! firstRow.Locator(".col-title").ClickAsync()
+ let detailPanel = this.Page.Locator(".detail-panel.expanded")
+ do! detailPanel.WaitForAsync(LocatorWaitForOptions(Timeout = 5000.0f))
+ let! count = detailPanel.CountAsync()
+ Assert.That(count, Is.EqualTo(1), "Clicking the row body should still expand the detail panel")
+ }