import { getConversation, getConversationMessages, checkMemoryPressure, getMemoryUsage } from './database.js';
import {
createAttachmentElement,
getMessageAttachments,
initAttachments,
reset as resetAttachments,
} from './attachments.js';
import { copyTextToClipboard } from './share.js';
import { VariableHeightVirtualList } from './virtual-list.js';
const VIRTUAL_CONFIG = {
MESSAGE_THRESHOLD: 50, ESTIMATED_MESSAGE_HEIGHT: 150, OVERSCAN: 3, };
const MEMORY_CONFIG = {
MAX_LOADED_CONVERSATIONS: 5, MEMORY_CHECK_INTERVAL: 30000, MEMORY_WARNING_THRESHOLD: 80, };
const loadedConversations = new Map();
let memoryCheckIntervalId = null;
const SANITIZE_CONFIG = {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'b', 'i', 'code', 'pre', 'ul', 'ol', 'li',
'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'mark', 'span',
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'del', 'sup', 'sub',
],
ALLOWED_ATTR: ['href', 'title', 'class', 'data-language', 'id', 'name'],
ALLOW_DATA_ATTR: false,
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input'],
FORBID_ATTR: ['onerror', 'onclick', 'onload', 'onmouseover', 'style'],
};
const ALLOWED_TAGS = new Set(SANITIZE_CONFIG.ALLOWED_TAGS);
const ALLOWED_ATTR = new Set([...SANITIZE_CONFIG.ALLOWED_ATTR, 'target', 'rel']);
const FORBID_TAGS = new Set(SANITIZE_CONFIG.FORBID_TAGS);
const FORBID_ATTR = new Set(SANITIZE_CONFIG.FORBID_ATTR);
let currentConversation = null;
let currentMessages = [];
let onBack = null;
let messageVirtualList = null; let attachmentState = createAttachmentState();
let activeConversationLoadId = 0;
let documentKeydownHandler = null;
let copyFeedbackTimeoutId = null;
let elements = {
container: null,
header: null,
messagesList: null,
};
export function initConversationViewer(container, backCallback) {
elements.container = container;
onBack = backCallback;
window.removeEventListener('cass:lock', handleArchiveLock);
window.addEventListener('cass:lock', handleArchiveLock);
}
export async function loadConversation(conversationId, highlightMessageId = null) {
const loadId = ++activeConversationLoadId;
try {
let conversation;
let messages;
if (loadedConversations.has(conversationId)) {
const cached = loadedConversations.get(conversationId);
loadedConversations.delete(conversationId);
loadedConversations.set(conversationId, cached);
conversation = cached.conversation;
messages = cached.messages;
console.debug(`[Conversation] Using cached conversation ${conversationId}`);
} else {
if (loadedConversations.size >= MEMORY_CONFIG.MAX_LOADED_CONVERSATIONS) {
unloadOldestConversation();
}
conversation = getConversation(conversationId);
if (!conversation) {
if (loadId === activeConversationLoadId) {
showError('Conversation not found');
}
return;
}
messages = getConversationMessages(conversationId);
loadedConversations.set(conversationId, {
conversation,
messages,
loadedAt: Date.now(),
});
console.debug(`[Conversation] Loaded and cached conversation ${conversationId} (cache size: ${loadedConversations.size})`);
}
if (checkMemoryPressure()) {
showMemoryWarning();
}
await ensureAttachmentsReady(loadId);
if (loadId !== activeConversationLoadId) {
return;
}
currentConversation = conversation;
currentMessages = messages;
render(conversation, messages, highlightMessageId);
} catch (error) {
if (loadId !== activeConversationLoadId) {
return;
}
console.error(`[Conversation] Failed to load conversation ${conversationId}:`, error);
teardownDocumentListeners();
destroyVirtualList();
currentConversation = null;
currentMessages = [];
showError('Failed to load conversation');
}
}
function createAttachmentState() {
return {
ready: false,
available: false,
dek: null,
exportId: null,
};
}
async function ensureAttachmentsReady(loadId = activeConversationLoadId) {
const state = attachmentState;
if (state.ready) {
return state.available;
}
const session = window.cassSession;
const dekBase64 = session?.dek;
const exportIdBase64 = session?.config?.export_id;
if (!dekBase64 || !exportIdBase64) {
if (state === attachmentState && loadId === activeConversationLoadId) {
state.ready = true;
state.available = false;
}
return false;
}
try {
const dek = base64ToBytes(dekBase64);
const exportId = base64ToBytes(exportIdBase64);
const manifest = await initAttachments(dek, exportId);
if (state !== attachmentState || loadId !== activeConversationLoadId) {
return false;
}
state.dek = dek;
state.exportId = exportId;
state.available = Boolean(manifest?.entries?.length);
state.ready = true;
return state.available;
} catch (error) {
if (state !== attachmentState || loadId !== activeConversationLoadId) {
return false;
}
if (error?.code === 'ATTACHMENT_REQUEST_INVALIDATED') {
return false;
}
console.warn('[Conversation] Attachment manifest unavailable:', error);
state.ready = false;
state.available = false;
return false;
}
}
function render(conv, messages, highlightId) {
destroyVirtualList();
const formattedDate = formatDate(conv.started_at);
const duration = conv.ended_at ? formatDuration(conv.ended_at - conv.started_at) : null;
const useVirtualScrolling = messages.length > VIRTUAL_CONFIG.MESSAGE_THRESHOLD;
elements.container.innerHTML = `
<div class="conversation-container">
<header class="conversation-header">
<button id="back-btn" type="button" class="back-btn" aria-label="Back to search">
←
</button>
<div class="conversation-title">
<h2>${escapeHtml(conv.title || 'Untitled conversation')}</h2>
<div class="meta">
<span class="conv-agent">${escapeHtml(formatAgentName(conv.agent))}</span>
<span class="conv-date">${escapeHtml(formattedDate)}</span>
${duration ? `<span class="conv-duration">${escapeHtml(duration)}</span>` : ''}
<span class="conv-count">${conv.message_count} message${conv.message_count !== 1 ? 's' : ''}</span>
${useVirtualScrolling ? '<span class="virtual-indicator" title="Virtual scrolling enabled for performance">⚡</span>' : ''}
</div>
</div>
<div class="conversation-actions">
<button id="copy-btn" type="button" class="btn btn-small" aria-label="Copy conversation">
📋 Copy
</button>
</div>
</header>
${conv.workspace ? `
<div class="conversation-workspace">
<span class="workspace-label">Workspace:</span>
<code>${escapeHtml(conv.workspace)}</code>
</div>
` : ''}
<div class="messages-list ${useVirtualScrolling ? 'virtual-messages' : ''}" id="messages-list">
</div>
</div>
`;
elements.header = elements.container.querySelector('.conversation-header');
elements.messagesList = document.getElementById('messages-list');
if (useVirtualScrolling) {
renderVirtualMessages(messages, highlightId);
} else {
renderDirectMessages(messages, highlightId);
}
setupEventListeners();
if (highlightId && !useVirtualScrolling) {
scrollToMessage(highlightId);
}
}
function renderVirtualMessages(messages, highlightId) {
elements.messagesList.style.height = 'calc(100vh - 200px)';
elements.messagesList.style.minHeight = '400px';
elements.messagesList.style.overflow = 'auto';
messageVirtualList = new VariableHeightVirtualList({
container: elements.messagesList,
totalCount: messages.length,
estimatedItemHeight: VIRTUAL_CONFIG.ESTIMATED_MESSAGE_HEIGHT,
renderItem: (index) => createMessageElement(messages[index], index, messages[index].id === highlightId),
overscan: VIRTUAL_CONFIG.OVERSCAN,
});
console.debug(`[Conversation] Using virtual scrolling for ${messages.length} messages`);
if (highlightId) {
const highlightIndex = messages.findIndex(m => m.id === highlightId);
if (highlightIndex >= 0) {
setTimeout(() => {
messageVirtualList.scrollToIndex(highlightIndex, 'center');
}, 100);
}
}
}
function renderDirectMessages(messages, highlightId) {
const html = messages.map((msg, idx) => renderMessage(msg, idx, msg.id === highlightId)).join('');
elements.messagesList.innerHTML = html;
hydrateDirectMessageAttachments(messages);
applySyntaxHighlighting();
}
function createMessageElement(message, index, isHighlighted = false) {
const roleClass = message.role === 'user' ? 'user' : 'assistant';
const highlightClass = isHighlighted ? 'highlighted' : '';
const time = message.created_at ? formatTime(message.created_at) : '';
const renderedContent = renderMarkdown(message.content);
const article = document.createElement('article');
article.className = `message ${roleClass} ${highlightClass}`;
article.id = `message-${message.id}`;
article.dataset.messageId = message.id;
article.innerHTML = `
<header class="message-header">
<span class="message-role ${roleClass}">
${roleClass === 'user' ? '👤 User' : '🤖 Assistant'}
</span>
${message.model ? `<span class="message-model">${escapeHtml(message.model)}</span>` : ''}
<span class="message-time">${escapeHtml(time)}</span>
</header>
<div class="message-content">
${renderedContent}
</div>
`;
appendAttachmentsToMessage(article, message);
requestAnimationFrame(() => {
highlightCodeInElement(article);
});
return article;
}
function highlightCodeInElement(element) {
if (typeof window.Prism !== 'undefined') {
const codeBlocks = element.querySelectorAll('pre code[data-language]');
codeBlocks.forEach(block => {
const lang = block.dataset.language;
if (window.Prism.languages[lang]) {
block.innerHTML = window.Prism.highlight(
block.textContent,
window.Prism.languages[lang],
lang
);
block.parentElement.classList.add(`language-${lang}`);
}
});
}
}
function destroyVirtualList() {
if (messageVirtualList) {
messageVirtualList.destroy();
messageVirtualList = null;
}
}
function renderMessage(message, index, isHighlighted = false) {
const roleClass = message.role === 'user' ? 'user' : 'assistant';
const highlightClass = isHighlighted ? 'highlighted' : '';
const time = message.created_at ? formatTime(message.created_at) : '';
const renderedContent = renderMarkdown(message.content);
return `
<article
class="message ${roleClass} ${highlightClass}"
id="message-${message.id}"
data-message-id="${message.id}"
>
<header class="message-header">
<span class="message-role ${roleClass}">
${roleClass === 'user' ? '👤 User' : '🤖 Assistant'}
</span>
${message.model ? `<span class="message-model">${escapeHtml(message.model)}</span>` : ''}
<span class="message-time">${escapeHtml(time)}</span>
</header>
<div class="message-content">
${renderedContent}
</div>
</article>
`;
}
function hydrateDirectMessageAttachments(messages) {
if (!attachmentState.available) {
return;
}
const byId = new Map(messages.map(message => [String(message.id), message]));
const renderedMessages = elements.messagesList.querySelectorAll('.message[data-message-id]');
renderedMessages.forEach(messageElement => {
const message = byId.get(messageElement.dataset.messageId);
if (message) {
appendAttachmentsToMessage(messageElement, message);
}
});
}
function appendAttachmentsToMessage(messageElement, message) {
if (!attachmentState.available) {
return;
}
const attachments = getMessageAttachments(message.id);
if (!attachments.length) {
return;
}
const contentElement = messageElement.querySelector('.message-content');
if (!contentElement || contentElement.querySelector('.message-attachments')) {
return;
}
const attachmentsContainer = document.createElement('div');
attachmentsContainer.className = 'message-attachments';
const label = document.createElement('div');
label.className = 'message-attachments-label';
label.textContent = attachments.length === 1 ? 'Attachment' : 'Attachments';
attachmentsContainer.appendChild(label);
attachments.forEach(entry => {
attachmentsContainer.appendChild(
createAttachmentElement(entry, attachmentState.dek, attachmentState.exportId)
);
});
contentElement.appendChild(attachmentsContainer);
}
function handleArchiveLock() {
activeConversationLoadId += 1;
currentConversation = null;
currentMessages = [];
teardownDocumentListeners();
destroyVirtualList();
clearAllCache();
attachmentState = createAttachmentState();
resetAttachments();
}
function setupEventListeners() {
teardownDocumentListeners();
const backBtn = document.getElementById('back-btn');
backBtn?.addEventListener('click', () => {
if (onBack) {
onBack();
}
});
const copyBtn = document.getElementById('copy-btn');
copyBtn?.addEventListener('click', () => {
copyConversation();
});
documentKeydownHandler = (e) => {
if (e.key === 'Escape' && onBack) {
onBack();
}
};
document.addEventListener('keydown', documentKeydownHandler);
}
function teardownDocumentListeners() {
if (documentKeydownHandler) {
document.removeEventListener('keydown', documentKeydownHandler);
documentKeydownHandler = null;
}
}
function renderMarkdown(content) {
if (!content) return '';
if (typeof window.marked !== 'undefined') {
try {
const html = window.marked.parse(content);
return sanitizeHtml(html);
} catch (error) {
console.warn('[Conversation] Markdown rendering failed:', error);
}
}
return sanitizeHtml(simpleMarkdown(content));
}
function base64ToBytes(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
export function sanitizeDestinationUrl(value) {
const url = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
const normalized = Array.from(url)
.filter(ch => !/\s/.test(ch) && !/[\u0000-\u001F\u007F]/.test(ch))
.join('')
.toLowerCase();
if (
normalized.startsWith('javascript:')
|| normalized.startsWith('vbscript:')
|| normalized.startsWith('data:')
) {
return '#';
}
return url;
}
function simpleMarkdown(text) {
let html = escapeHtml(text);
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
const langClass = lang ? ` data-language="${lang}"` : '';
return `<pre><code${langClass}>${code.trim()}</code></pre>`;
});
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
html = html.replace(/_([^_]+)_/g, '<em>$1</em>');
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => {
const safeHref = escapeHtml(sanitizeDestinationUrl(href));
return `<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${label}</a>`;
});
html = html.replace(/\n\n/g, '</p><p>');
html = `<p>${html}</p>`;
html = html.replace(/<p>\s*<\/p>/g, '');
html = html.replace(/<p>(<h[1-6]>)/g, '$1');
html = html.replace(/(<\/h[1-6]>)<\/p>/g, '$1');
html = html.replace(/<p>(<pre>)/g, '$1');
html = html.replace(/(<\/pre>)<\/p>/g, '$1');
return html;
}
function sanitizeHtml(html) {
if (typeof window.DOMPurify !== 'undefined') {
return window.DOMPurify.sanitize(html, SANITIZE_CONFIG);
}
const template = document.createElement('template');
template.innerHTML = html;
const allElements = Array.from(template.content.querySelectorAll('*'));
allElements.forEach(el => {
const tag = el.tagName.toLowerCase();
if (FORBID_TAGS.has(tag)) {
el.remove();
return;
}
if (!ALLOWED_TAGS.has(tag)) {
el.replaceWith(...Array.from(el.childNodes));
return;
}
Array.from(el.attributes).forEach(attr => {
const name = attr.name.toLowerCase();
if (name.startsWith('on') || FORBID_ATTR.has(name) || !ALLOWED_ATTR.has(name)) {
el.removeAttribute(attr.name);
return;
}
if (name === 'href') {
el.setAttribute('href', sanitizeDestinationUrl(attr.value));
}
});
if (tag === 'a') {
el.setAttribute('target', '_blank');
el.setAttribute('rel', 'noopener noreferrer');
}
});
return template.innerHTML;
}
function applySyntaxHighlighting() {
if (typeof window.Prism !== 'undefined') {
const codeBlocks = elements.container.querySelectorAll('pre code[data-language]');
codeBlocks.forEach(block => {
const lang = block.dataset.language;
if (window.Prism.languages[lang]) {
block.innerHTML = window.Prism.highlight(
block.textContent,
window.Prism.languages[lang],
lang
);
block.parentElement.classList.add(`language-${lang}`);
}
});
}
}
function scrollToMessage(messageId) {
setTimeout(() => {
const messageEl = document.getElementById(`message-${messageId}`);
if (messageEl) {
messageEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
messageEl.classList.add('highlight-flash');
setTimeout(() => {
messageEl.classList.remove('highlight-flash');
}, 2000);
}
}, 100);
}
async function copyConversation() {
if (!currentConversation || !currentMessages.length) return;
const text = formatConversationAsText(currentConversation, currentMessages);
try {
const copied = await copyTextToClipboard(text);
if (!copied) {
throw new Error('Clipboard copy failed');
}
showCopyFeedback('Copied!');
} catch (error) {
console.error('[Conversation] Copy failed:', error);
showCopyFeedback('Copy failed');
}
}
function formatConversationAsText(conv, messages) {
const lines = [
`# ${conv.title || 'Untitled conversation'}`,
`Agent: ${conv.agent}`,
`Date: ${formatDate(conv.started_at)}`,
conv.workspace ? `Workspace: ${conv.workspace}` : '',
'',
'---',
'',
];
messages.forEach(msg => {
const role = msg.role === 'user' ? 'User' : 'Assistant';
lines.push(`## ${role}:`);
lines.push('');
lines.push(msg.content);
lines.push('');
lines.push('---');
lines.push('');
});
return lines.filter(line => line !== null).join('\n');
}
function showCopyFeedback(message) {
const copyBtn = document.getElementById('copy-btn');
if (copyBtn) {
if (!copyBtn.dataset.defaultLabel) {
copyBtn.dataset.defaultLabel = copyBtn.innerHTML;
}
if (copyFeedbackTimeoutId !== null) {
clearTimeout(copyFeedbackTimeoutId);
copyFeedbackTimeoutId = null;
}
const defaultLabel = copyBtn.dataset.defaultLabel;
copyBtn.innerHTML = message;
copyFeedbackTimeoutId = window.setTimeout(() => {
if (copyBtn.isConnected) {
copyBtn.innerHTML = defaultLabel;
}
copyFeedbackTimeoutId = null;
}, 2000);
}
}
function showError(message) {
elements.container.innerHTML = `
<div class="conversation-container">
<div class="conversation-error">
<span class="error-icon">⚠️</span>
<p>${escapeHtml(message)}</p>
<button type="button" class="btn" id="error-back-btn">Go back</button>
</div>
</div>
`;
const backBtn = document.getElementById('error-back-btn');
backBtn?.addEventListener('click', () => {
if (onBack) {
onBack();
} else {
history.back();
}
});
}
function formatAgentName(agent) {
if (agent === undefined || agent === null || agent === '') return 'Unknown';
const value = String(agent);
return value.charAt(0).toUpperCase() + value.slice(1);
}
function formatDate(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
return date.toLocaleDateString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function formatTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
return date.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
});
}
function formatDuration(ms) {
if (!ms || ms < 0) return '';
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
}
if (minutes > 0) {
return `${minutes}m`;
}
return `${seconds}s`;
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
export function getCurrentConversationId() {
return currentConversation?.id || null;
}
export function getCurrentConversation() {
return currentConversation;
}
function unloadOldestConversation() {
const oldest = loadedConversations.keys().next().value;
if (oldest !== undefined) {
loadedConversations.delete(oldest);
console.debug(`[Conversation] Unloaded oldest conversation ${oldest} (cache size: ${loadedConversations.size})`);
}
}
export function clearOldConversations(keepCount = 1) {
const entries = Array.from(loadedConversations.entries());
const toRemove = entries.length - keepCount;
if (toRemove > 0) {
for (let i = 0; i < toRemove; i++) {
loadedConversations.delete(entries[i][0]);
}
console.debug(`[Conversation] Cleared ${toRemove} old conversations (cache size: ${loadedConversations.size})`);
}
}
function showMemoryWarning() {
if (document.getElementById('memory-warning')) return;
const usage = getMemoryUsage();
const percent = usage ? usage.percent.toFixed(1) : 'N/A';
const banner = document.createElement('div');
banner.id = 'memory-warning';
banner.className = 'memory-warning-banner';
banner.setAttribute('role', 'alert');
banner.innerHTML = `
<span class="memory-warning-icon" aria-hidden="true">⚠️</span>
<span class="memory-warning-text">Memory usage is high (${percent}%). Consider closing some conversations.</span>
<button id="memory-clear-btn" type="button" class="btn btn-small memory-clear-btn">
Clear Cache
</button>
<button class="memory-dismiss-btn" type="button" aria-label="Dismiss">✕</button>
`;
document.body.prepend(banner);
const clearBtn = document.getElementById('memory-clear-btn');
clearBtn?.addEventListener('click', () => {
clearOldConversations(1);
hideMemoryWarning();
});
const dismissBtn = banner.querySelector('.memory-dismiss-btn');
dismissBtn?.addEventListener('click', hideMemoryWarning);
}
function hideMemoryWarning() {
const banner = document.getElementById('memory-warning');
if (banner) {
banner.remove();
}
}
export function startMemoryMonitoring() {
if (memoryCheckIntervalId) return;
memoryCheckIntervalId = setInterval(() => {
if (checkMemoryPressure()) {
showMemoryWarning();
}
}, MEMORY_CONFIG.MEMORY_CHECK_INTERVAL);
console.debug('[Conversation] Memory monitoring started');
}
export function stopMemoryMonitoring() {
if (memoryCheckIntervalId) {
clearInterval(memoryCheckIntervalId);
memoryCheckIntervalId = null;
console.debug('[Conversation] Memory monitoring stopped');
}
}
export function getCacheStats() {
const memory = getMemoryUsage();
return {
cachedCount: loadedConversations.size,
maxCached: MEMORY_CONFIG.MAX_LOADED_CONVERSATIONS,
memoryUsed: memory?.used || 0,
memoryLimit: memory?.limit || 0,
memoryPercent: memory?.percent || 0,
};
}
export function clearViewer() {
activeConversationLoadId += 1;
destroyVirtualList();
teardownDocumentListeners();
currentConversation = null;
currentMessages = [];
elements.container.innerHTML = '';
}
export function cleanupConversationViewer() {
window.removeEventListener('cass:lock', handleArchiveLock);
clearViewer();
}
export function clearAllCache() {
loadedConversations.clear();
hideMemoryWarning();
console.debug('[Conversation] All cached conversations cleared');
}
export default {
initConversationViewer,
loadConversation,
getCurrentConversationId,
getCurrentConversation,
clearViewer,
cleanupConversationViewer,
clearAllCache,
clearOldConversations,
getCacheStats,
startMemoryMonitoring,
stopMemoryMonitoring,
};