mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-20 05:04:44 +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);
|
color: var(--userMessageText);
|
||||||
padding: var(--line-height);
|
padding: var(--line-height);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-message {
|
.assistant-message {
|
||||||
padding: 0;
|
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 {
|
.assistant-message > .message-timestamp {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,18 @@
|
||||||
bytes[i] = binary.charCodeAt(i);
|
bytes[i] = binary.charCodeAt(i);
|
||||||
}
|
}
|
||||||
const data = JSON.parse(new TextDecoder('utf-8').decode(bytes));
|
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
|
// DATA STRUCTURES
|
||||||
|
|
@ -777,16 +788,87 @@
|
||||||
return html;
|
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) {
|
function renderEntry(entry) {
|
||||||
const ts = formatTimestamp(entry.timestamp);
|
const ts = formatTimestamp(entry.timestamp);
|
||||||
const tsHtml = ts ? `<div class="message-timestamp">${ts}</div>` : '';
|
const tsHtml = ts ? `<div class="message-timestamp">${ts}</div>` : '';
|
||||||
const entryId = `entry-${entry.id}`;
|
const entryId = `entry-${entry.id}`;
|
||||||
|
const copyBtnHtml = renderCopyLinkButton(entry.id);
|
||||||
|
|
||||||
if (entry.type === 'message') {
|
if (entry.type === 'message') {
|
||||||
const msg = entry.message;
|
const msg = entry.message;
|
||||||
|
|
||||||
if (msg.role === 'user') {
|
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;
|
const content = msg.content;
|
||||||
|
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
|
|
@ -810,7 +892,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.role === 'assistant') {
|
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) {
|
for (const block of msg.content) {
|
||||||
if (block.type === 'text' && block.text.trim()) {
|
if (block.type === 'text' && block.text.trim()) {
|
||||||
|
|
@ -1023,7 +1105,7 @@
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateTo(targetId, scrollMode = 'target') {
|
function navigateTo(targetId, scrollMode = 'target', scrollToEntryId = null) {
|
||||||
currentLeafId = targetId;
|
currentLeafId = targetId;
|
||||||
const path = getPath(targetId);
|
const path = getPath(targetId);
|
||||||
|
|
||||||
|
|
@ -1045,15 +1127,32 @@
|
||||||
messagesEl.innerHTML = '';
|
messagesEl.innerHTML = '';
|
||||||
messagesEl.appendChild(fragment);
|
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
|
// Use setTimeout(0) to ensure DOM is fully laid out before scrolling
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const content = document.getElementById('content');
|
const content = document.getElementById('content');
|
||||||
if (scrollMode === 'bottom') {
|
if (scrollMode === 'bottom') {
|
||||||
content.scrollTop = content.scrollHeight;
|
content.scrollTop = content.scrollHeight;
|
||||||
} else if (scrollMode === 'target') {
|
} 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) {
|
if (targetEl) {
|
||||||
targetEl.scrollIntoView({ block: 'center' });
|
targetEl.scrollIntoView({ block: 'center' });
|
||||||
|
// Briefly highlight the target message
|
||||||
|
if (scrollToEntryId) {
|
||||||
|
targetEl.classList.add('highlight');
|
||||||
|
setTimeout(() => targetEl.classList.remove('highlight'), 2000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 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) {
|
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) {
|
} else if (entries.length > 0) {
|
||||||
// Fallback: use last entry if no leafId
|
// Fallback: use last entry if no leafId
|
||||||
navigateTo(entries[entries.length - 1].id, 'none');
|
navigateTo(entries[entries.length - 1].id, 'none');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue