mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 17:00:59 +00:00
feat: Add copy-link button to share viewer messages (#477)
* feat: add copy-link button to share viewer messages Implements the feature requested in #437: - Add a small link icon button that appears on hovering over user/assistant messages in the share viewer - Clicking the button copies a shareable URL to clipboard with visual feedback - URL format: base?gistId&leafId=<active-leaf>&targetId=<message-id> - When loading a URL with leafId and targetId params: - Navigate to the specified leaf node - Scroll to and briefly highlight the target message This enables users to share links to specific messages within a session. * fix: preserve gist ID format and add clipboard fallback - Fix URL format to produce ?gistId&leafId=... instead of ?gistId=&leafId=... (preserves the bare key format expected by the backend) - Add execCommand fallback for clipboard copy on HTTP contexts where navigator.clipboard is unavailable
This commit is contained in:
parent
e182b123a9
commit
cf10e9592a
2 changed files with 167 additions and 7 deletions
|
|
@ -280,10 +280,65 @@
|
|||
color: var(--userMessageText);
|
||||
padding: var(--line-height);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.assistant-message {
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Copy link button - appears on hover */
|
||||
.copy-link-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 6px;
|
||||
background: var(--container-bg);
|
||||
border: 1px solid var(--dim);
|
||||
border-radius: 4px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s, color 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.user-message:hover .copy-link-btn,
|
||||
.assistant-message:hover .copy-link-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.copy-link-btn:hover {
|
||||
background: var(--accent);
|
||||
color: var(--body-bg);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.copy-link-btn.copied {
|
||||
background: var(--success, #22c55e);
|
||||
color: white;
|
||||
border-color: var(--success, #22c55e);
|
||||
}
|
||||
|
||||
/* Highlight effect for deep-linked messages */
|
||||
.user-message.highlight,
|
||||
.assistant-message.highlight {
|
||||
animation: highlight-pulse 2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes highlight-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 3px var(--accent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.assistant-message > .message-timestamp {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,18 @@
|
|||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
const data = JSON.parse(new TextDecoder('utf-8').decode(bytes));
|
||||
const { header, entries, leafId, systemPrompt, tools } = data;
|
||||
const { header, entries, leafId: defaultLeafId, systemPrompt, tools } = data;
|
||||
|
||||
// ============================================================
|
||||
// URL PARAMETER HANDLING
|
||||
// ============================================================
|
||||
|
||||
// Parse URL parameters for deep linking: leafId and targetId
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlLeafId = urlParams.get('leafId');
|
||||
const urlTargetId = urlParams.get('targetId');
|
||||
// Use URL leafId if provided, otherwise fall back to session default
|
||||
const leafId = urlLeafId || defaultLeafId;
|
||||
|
||||
// ============================================================
|
||||
// DATA STRUCTURES
|
||||
|
|
@ -777,16 +788,87 @@
|
|||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a shareable URL for a specific message.
|
||||
* URL format: base?gistId&leafId=<leafId>&targetId=<entryId>
|
||||
*/
|
||||
function buildShareUrl(entryId) {
|
||||
const url = new URL(window.location.href);
|
||||
// Find the gist ID (first query param without value, e.g., ?abc123)
|
||||
const gistId = Array.from(url.searchParams.keys()).find(k => !url.searchParams.get(k));
|
||||
// Build search string manually to preserve ?gistId format (not ?gistId=)
|
||||
const params = new URLSearchParams();
|
||||
params.set('leafId', currentLeafId);
|
||||
params.set('targetId', entryId);
|
||||
url.search = gistId ? `?${gistId}&${params.toString()}` : `?${params.toString()}`;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard with visual feedback.
|
||||
* Uses navigator.clipboard with fallback to execCommand for HTTP contexts.
|
||||
*/
|
||||
async function copyToClipboard(text, button) {
|
||||
let success = false;
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
success = true;
|
||||
}
|
||||
} catch (err) {
|
||||
// Clipboard API failed, try fallback
|
||||
}
|
||||
|
||||
// Fallback for HTTP or when Clipboard API is unavailable
|
||||
if (!success) {
|
||||
try {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
success = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (success && button) {
|
||||
const originalHtml = button.innerHTML;
|
||||
button.innerHTML = '✓';
|
||||
button.classList.add('copied');
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHtml;
|
||||
button.classList.remove('copied');
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the copy-link button HTML for a message.
|
||||
*/
|
||||
function renderCopyLinkButton(entryId) {
|
||||
return `<button class="copy-link-btn" data-entry-id="${entryId}" title="Copy link to this message">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function renderEntry(entry) {
|
||||
const ts = formatTimestamp(entry.timestamp);
|
||||
const tsHtml = ts ? `<div class="message-timestamp">${ts}</div>` : '';
|
||||
const entryId = `entry-${entry.id}`;
|
||||
const copyBtnHtml = renderCopyLinkButton(entry.id);
|
||||
|
||||
if (entry.type === 'message') {
|
||||
const msg = entry.message;
|
||||
|
||||
if (msg.role === 'user') {
|
||||
let html = `<div class="user-message" id="${entryId}">${tsHtml}`;
|
||||
let html = `<div class="user-message" id="${entryId}">${copyBtnHtml}${tsHtml}`;
|
||||
const content = msg.content;
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
|
|
@ -810,7 +892,7 @@
|
|||
}
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
let html = `<div class="assistant-message" id="${entryId}">${tsHtml}`;
|
||||
let html = `<div class="assistant-message" id="${entryId}">${copyBtnHtml}${tsHtml}`;
|
||||
|
||||
for (const block of msg.content) {
|
||||
if (block.type === 'text' && block.text.trim()) {
|
||||
|
|
@ -1023,7 +1105,7 @@
|
|||
return node;
|
||||
}
|
||||
|
||||
function navigateTo(targetId, scrollMode = 'target') {
|
||||
function navigateTo(targetId, scrollMode = 'target', scrollToEntryId = null) {
|
||||
currentLeafId = targetId;
|
||||
const path = getPath(targetId);
|
||||
|
||||
|
|
@ -1045,15 +1127,32 @@
|
|||
messagesEl.innerHTML = '';
|
||||
messagesEl.appendChild(fragment);
|
||||
|
||||
// Attach click handlers for copy-link buttons
|
||||
messagesEl.querySelectorAll('.copy-link-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const entryId = btn.dataset.entryId;
|
||||
const shareUrl = buildShareUrl(entryId);
|
||||
copyToClipboard(shareUrl, btn);
|
||||
});
|
||||
});
|
||||
|
||||
// Use setTimeout(0) to ensure DOM is fully laid out before scrolling
|
||||
setTimeout(() => {
|
||||
const content = document.getElementById('content');
|
||||
if (scrollMode === 'bottom') {
|
||||
content.scrollTop = content.scrollHeight;
|
||||
} else if (scrollMode === 'target') {
|
||||
const targetEl = document.getElementById(`entry-${targetId}`);
|
||||
// If scrollToEntryId is provided, scroll to that specific entry
|
||||
const scrollTargetId = scrollToEntryId || targetId;
|
||||
const targetEl = document.getElementById(`entry-${scrollTargetId}`);
|
||||
if (targetEl) {
|
||||
targetEl.scrollIntoView({ block: 'center' });
|
||||
// Briefly highlight the target message
|
||||
if (scrollToEntryId) {
|
||||
targetEl.classList.add('highlight');
|
||||
setTimeout(() => targetEl.classList.remove('highlight'), 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
|
|
@ -1188,9 +1287,15 @@
|
|||
}
|
||||
});
|
||||
|
||||
// Initial render - don't scroll, stay at top
|
||||
// Initial render
|
||||
// If URL has targetId, scroll to that specific message; otherwise stay at top
|
||||
if (leafId) {
|
||||
navigateTo(leafId, 'none');
|
||||
if (urlTargetId && byId.has(urlTargetId)) {
|
||||
// Deep link: navigate to leaf and scroll to target message
|
||||
navigateTo(leafId, 'target', urlTargetId);
|
||||
} else {
|
||||
navigateTo(leafId, 'none');
|
||||
}
|
||||
} else if (entries.length > 0) {
|
||||
// Fallback: use last entry if no leafId
|
||||
navigateTo(entries[entries.length - 1].id, 'none');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue