// ==UserScript==
// @name Claude Message Info
// @namespace http://tampermonkey.net/
// @version 0.0.21
// @description Add metadata to Claude messages: index, branch, timestamp, UUID, artifact commands
// @author MRL
// @match https://claude.ai/*
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// =============================================
// CONFIGURATION & SELECTORS
// =============================================
const CONFIG = {
retryAttempts: 3,
retryDelay: 500,
debounceDelay: 300,
streamingDebounce: 300,
initialDelay: 1000,
apiTimeout: 10000,
newChatPollingInterval: 500,
newChatMaxWaitTime: 15000
};
const SELECTORS = {
// Multiple fallback selectors for message containers
messageContainers: [
'.flex-1.flex-col.gap-3 > div[data-test-render-count]', // Primary: direct children in chat list
'div[data-test-render-count]', // Fallback: any render count wrapper
'.message-container > div', // Future-proof: potential class changes
'[role="article"]' // Semantic: ARIA article elements
],
// Selectors for message content areas
messageGroups: [
'.group.relative', // Primary: message card container
'.message-group', // Fallback: semantic class name
'[data-message-group]' // Future-proof: data attribute
],
// Selectors for artifact blocks
artifactBlocks: [
'.artifact-block-cell', // Primary: artifact wrapper
'[data-artifact-block]', // Future-proof: data attribute
'.artifact-container' // Fallback: semantic class name
],
// Selectors for artifact version info
artifactVersionInfo: [
'.text-xs.line-clamp-1.text-text-400', // Primary: version text line
'.artifact-version', // Fallback: semantic class name
'[data-artifact-version]' // Future-proof: data attribute
],
// Selectors for streaming indicators
streamingIndicators: [
'[data-is-streaming]', // Primary: streaming state attribute
'.streaming-indicator', // Fallback: semantic class name
'[aria-busy="true"]' // Semantic: ARIA busy state
],
// Selectors for navigation buttons (branch switching)
navigationButtons: [
'button svg path[d*="M13.2402"]', // Left arrow SVG path
'button svg path[d*="M6.13378"]', // Right arrow SVG path
'[data-navigation-button]' // Future-proof: data attribute
],
// Edit mode form selector
editForm: 'form textarea[id]' // Form with textarea having ID attribute
};
// =============================================
// UTILITY FUNCTIONS
// =============================================
/**
* Universal selector finder - tries multiple selectors until one works
*/
function findElements(selectorArray, context = document) {
for (const selector of selectorArray) {
try {
const elements = context.querySelectorAll(selector);
if (elements.length > 0) {
return Array.from(elements);
}
} catch (e) {
console.warn(`[Claude Timestamps] Invalid selector: ${selector}`, e);
}
}
return [];
}
/**
* Find first matching element from selector array
*/
function findElement(selectorArray, context = document) {
for (const selector of selectorArray) {
try {
const element = context.querySelector(selector);
if (element) return element;
} catch (e) {
console.warn(`[Claude Timestamps] Invalid selector: ${selector}`, e);
}
}
return null;
}
/**
* Safe element query with error handling
*/
function safeQuery(element, selector) {
try {
return element?.querySelector(selector) || null;
} catch (e) {
return null;
}
}
/**
* Check if element is visible in viewport
*/
function isElementVisible(element) {
if (!element) return false;
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
/**
* Wait for condition to be true with timeout
*/
async function waitForCondition(conditionFn, timeout = 10000, interval = 100) {
const startTime = Date.now();
return new Promise((resolve, reject) => {
const check = () => {
if (conditionFn()) {
resolve(true);
} else if (Date.now() - startTime > timeout) {
reject(new Error('Timeout waiting for condition'));
} else {
setTimeout(check, interval);
}
};
check();
});
}
// =============================================
// API FUNCTIONS
// =============================================
/**
* Extracts conversation ID from current URL
*/
function getConversationId() {
const match = window.location.pathname.match(/\/chat\/([^/?]+)/);
return match ? match[1] : null;
}
/**
* Gets organization ID from browser cookies
*/
function getOrgId() {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'lastActiveOrg') {
return value;
}
}
throw new Error('Could not find organization ID');
}
/**
* Fetches conversation data from Claude API
*/
async function getConversationData() {
const conversationId = getConversationId();
if (!conversationId) {
return null;
}
const orgId = getOrgId();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CONFIG.apiTimeout);
try {
const response = await fetch(
`/api/organizations/${orgId}/chat_conversations/${conversationId}?tree=true&rendering_mode=messages&render_all_tools=true`,
{ signal: controller.signal }
);
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
console.error('[Claude Timestamps] API request timeout');
}
throw error;
}
}
// =============================================
// BRANCH BUILDING FUNCTIONS
// =============================================
/**
* Builds conversation tree structure
*/
function buildConversationTree(messages) {
const messageMap = new Map();
const rootMessages = [];
// Create message map
messages.forEach(message => {
messageMap.set(message.uuid, {
...message,
children: []
});
});
// Build parent-child relationships
messages.forEach(message => {
const messageNode = messageMap.get(message.uuid);
const parentUuid = message.parent_message_uuid;
if (parentUuid &&
parentUuid !== "00000000-0000-4000-8000-000000000000" &&
messageMap.has(parentUuid)) {
const parent = messageMap.get(parentUuid);
parent.children.push(messageNode);
} else {
rootMessages.push(messageNode);
}
});
return { messageMap, rootMessages };
}
/**
* Finds main branch path from current_leaf_message_uuid
*/
function findMainBranchPath(tree, currentLeafUuid) {
if (!currentLeafUuid) {
return [];
}
const mainPath = [];
let currentMessage = tree.messageMap.get(currentLeafUuid);
while (currentMessage) {
mainPath.unshift(currentMessage); // Add to beginning
const parentUuid = currentMessage.parent_message_uuid;
if (parentUuid === "00000000-0000-4000-8000-000000000000" || !parentUuid) {
break;
}
currentMessage = tree.messageMap.get(parentUuid);
}
return mainPath;
}
/**
* Finds main branch path from message with maximum index
*/
function buildPathFromMaxIndex(tree) {
// Find message with maximum index
let maxIndexMessage = null;
let maxIndex = -1;
tree.messageMap.forEach(message => {
if (message.index > maxIndex) {
maxIndex = message.index;
maxIndexMessage = message;
}
});
if (!maxIndexMessage) return [];
// Build path backwards through parent_message_uuid
const mainPath = [];
let currentMessage = maxIndexMessage;
while (currentMessage) {
mainPath.unshift(currentMessage);
const parentUuid = currentMessage.parent_message_uuid;
if (parentUuid === "00000000-0000-4000-8000-000000000000" || !parentUuid) {
break;
}
currentMessage = tree.messageMap.get(parentUuid);
}
return mainPath;
}
/**
* Gets all branch information including branch points
*/
function getAllBranchInfo(tree) {
const messageToBranch = new Map();
// Find main branch (by max index)
const mainBranchPath = buildPathFromMaxIndex(tree);
const mainBranchUuids = new Set(mainBranchPath.map(msg => msg.uuid));
// Two-pass approach:
// Pass 1: Collect all branch starting points
const branchStartPoints = [];
function collectBranchPoints(node) {
if (node.children.length > 1) {
// Multiple children = branch point
const sortedChildren = [...node.children].sort((a, b) => a.index - b.index);
// Skip first child (continues parent branch)
for (let i = 1; i < sortedChildren.length; i++) {
branchStartPoints.push({
index: sortedChildren[i].index,
node: sortedChildren[i]
});
}
}
// Recurse to all children
node.children.forEach(child => collectBranchPoints(child));
}
// Collect branch points from all roots
tree.rootMessages.forEach(root => collectBranchPoints(root));
// Also add additional root messages as branch starts (if multiple roots)
for (let i = 1; i < tree.rootMessages.length; i++) {
branchStartPoints.push({
index: tree.rootMessages[i].index,
node: tree.rootMessages[i]
});
}
// Sort branch start points by index
branchStartPoints.sort((a, b) => a.index - b.index);
// Create a map from node to branch number
const nodeToBranchNumber = new Map();
branchStartPoints.forEach((point, i) => {
nodeToBranchNumber.set(point.node, i + 2); // +2 because main is 1
});
// Pass 2: Assign branch numbers
function assignBranch(node, currentBranchNumber) {
const isMainBranch = mainBranchUuids.has(node.uuid);
messageToBranch.set(node.uuid, {
branchIndex: currentBranchNumber,
isMainBranch: isMainBranch
});
if (node.children.length === 0) return;
if (node.children.length === 1) {
// Single child continues current branch
assignBranch(node.children[0], currentBranchNumber);
} else {
// Multiple children
const sortedChildren = [...node.children].sort((a, b) => a.index - b.index);
// First child continues current branch
assignBranch(sortedChildren[0], currentBranchNumber);
// Other children use their assigned branch numbers
for (let i = 1; i < sortedChildren.length; i++) {
const child = sortedChildren[i];
const childBranchNumber = nodeToBranchNumber.get(child) || currentBranchNumber;
assignBranch(child, childBranchNumber);
}
}
}
// Start assignment from first root with branch 1
if (tree.rootMessages.length > 0) {
assignBranch(tree.rootMessages[0], 1);
}
// Assign other roots with their branch numbers
for (let i = 1; i < tree.rootMessages.length; i++) {
const root = tree.rootMessages[i];
const branchNumber = nodeToBranchNumber.get(root) || 1;
assignBranch(root, branchNumber);
}
return {
messageToBranch,
mainBranchUuids
};
}
// =============================================
// DOM MANIPULATION
// =============================================
/**
* Formats timestamp for display
*/
function formatTimestamp(isoString) {
const date = new Date(isoString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* Injects metadata into a DOM element
*/
function injectMetadata(container, messageData, messageToBranch, domPosition, totalDomMessages) {
// Check if already added
if (container.querySelector('.claude-timestamp-metadata')) {
return;
}
// Get branch info for this specific message
const branchInfo = messageToBranch.get(messageData.uuid);
const branchNumber = branchInfo ? branchInfo.branchIndex : '?';
// Hard
// Determine if main or side branch BY SPECIFIC MESSAGE UUID
// Not by branch info, because a branch can contain both main and side messages
const isMainBranch = branchInfo ? branchInfo.isMainBranch : false;
const branchStatus = isMainBranch ? 'Main' : 'Side';
// Check if message was canceled
const isCanceled = messageData.stop_reason === 'user_canceled';
const canceledText = isCanceled ? ' | CANCELED' : '';
// Create metadata element
const metadata = document.createElement('div');
metadata.className = 'claude-timestamp-metadata';
metadata.style.cssText = `
position: absolute;
top: -15px;
right: 8px;
font-size: 10px;
color: var(--text-400, #94a3b8);
opacity: 0.7;
padding: 2px 6px;
background: var(--bg-200, rgba(0, 0, 0, 0.05));
border-radius: 4px;
/* backdrop-filter: blur(4px); */
z-index: 2;
pointer-events: auto;
white-space: nowrap;
`;
const timestamp = formatTimestamp(messageData.created_at);
metadata.textContent = `#${messageData.index} [${domPosition}/${totalDomMessages}] | Branch ${branchNumber} | ${branchStatus}${canceledText} | ${timestamp}`;
const tooltipLines = [
`API Index: ${messageData.index}`,
`DOM Position: ${domPosition} of ${totalDomMessages}`,
`Branch: ${branchNumber}`,
`Status: ${branchStatus}`,
`Created: ${new Date(messageData.created_at).toLocaleString()}`,
`UUID: ${messageData.uuid}`
];
if (isCanceled) {
tooltipLines.push('Stop Reason: User Canceled');
}
metadata.title = tooltipLines.join('\n');
// Find where to insert
const groupDiv = findElement(SELECTORS.messageGroups, container);
if (groupDiv) {
groupDiv.style.position = 'relative';
groupDiv.appendChild(metadata);
}
}
/**
* Injects artifact command badge into artifact block
*/
function injectArtifactMetadata(artifactBlock, command, canceled) {
// Check if already added
if (artifactBlock.querySelector('.claude-artifact-command')) {
return;
}
// Find the version info line
const versionLine = findElement(SELECTORS.artifactVersionInfo, artifactBlock);
if (!versionLine) return;
// Create command badge
const commandBadge = document.createElement('span');
commandBadge.className = 'claude-artifact-command';
const commandColors = {
'create': '#22c55e',
'rewrite': '#eab308',
'update': '#3b82f6'
};
const bgColor = commandColors[command] || '#6b7280';
commandBadge.style.cssText = `
display: inline-block;
padding: 1px 4px;
margin-left: 4px;
font-size: 9px;
font-weight: 600;
color: white;
background-color: ${bgColor};
border-radius: 3px;
text-transform: uppercase;
vertical-align: middle;
`;
commandBadge.textContent = command + ' ARTIFACT';
if (canceled) {
commandBadge.style.opacity = '0.5';
commandBadge.title = 'Generation was canceled';
} else {
commandBadge.title = `Command: ${command}`;
}
// IMPORTANT: Just append the badge without clearing versionLine content
try {
versionLine.appendChild(commandBadge);
} catch (e) {
// Silently ignore if insertion fails
}
}
/**
* Removes all existing metadata badges
*/
function clearMetadata() {
document.querySelectorAll('.claude-timestamp-metadata').forEach(el => el.remove());
document.querySelectorAll('.claude-artifact-command').forEach(el => el.remove());
}
// =============================================
// INJECTION LOGIC
// =============================================
/**
* Main function to inject timestamps into all messages
*/
async function injectTimestamps(retryCount = 0) {
try {
// console.log('[Claude Timestamps] 🔄 Starting injection...');
// Get conversation data from API
const conversationData = await getConversationData();
if (!conversationData || !conversationData.chat_messages || conversationData.chat_messages.length === 0) {
console.log('[Claude Timestamps] ❌ No messages found in API');
return false;
}
// Build conversation tree
const tree = buildConversationTree(conversationData.chat_messages);
// Get all branch information (uses MAX INDEX for Main/Side determination)
const { messageToBranch, mainBranchUuids } = getAllBranchInfo(tree);
// Find active branch from current_leaf_message_uuid (for DOM matching)
let activeBranch = findMainBranchPath(tree, conversationData.current_leaf_message_uuid);
// FALLBACK: If current_leaf_message_uuid is null or not found, use max index path
if (activeBranch.length === 0) {
console.log('[Claude Timestamps] ⚠️ current_leaf_message_uuid not found, using max index fallback');
activeBranch = buildPathFromMaxIndex(tree);
}
// Still no branch? Something is wrong
if (activeBranch.length === 0) {
console.log('[Claude Timestamps] ❌ No active branch found even with fallback');
return false;
}
console.log(`[Claude Timestamps] ✅ Using branch with ${activeBranch.length} messages`);
// Get DOM elements using flexible selectors
const messageContainers = findElements(SELECTORS.messageContainers);
if (messageContainers.length === 0) {
// Retry if DOM not ready yet (max 3 attempts)
if (retryCount < CONFIG.retryAttempts) {
console.log(`[Claude Timestamps] ⏳ DOM not ready, retrying... (${retryCount + 1}/${CONFIG.retryAttempts})`);
setTimeout(() => injectTimestamps(retryCount + 1), CONFIG.retryDelay);
return false;
} else {
console.log(`[Claude Timestamps] ❌ No message containers found in DOM after ${CONFIG.retryAttempts} retries`);
return false;
}
}
console.log(`[Claude Timestamps] 📊 API (active branch): ${activeBranch.length} messages, DOM: ${messageContainers.length} elements`);
// Clear old metadata
clearMetadata();
// Match and inject
const totalDomMessages = messageContainers.length;
// Process each message
activeBranch.forEach((msg, index) => {
if (index >= messageContainers.length) return;
const container = messageContainers[index];
// Inject message metadata
injectMetadata(
container,
msg,
messageToBranch,
index + 1,
totalDomMessages
);
// Look for artifacts in this message's content
if (msg.content && Array.isArray(msg.content)) {
const artifacts = msg.content.filter(item =>
item.type === 'tool_use' && item.name === 'artifacts' && item.input
);
if (artifacts.length > 0) {
console.log(`[Claude Timestamps] 🎨 Found ${artifacts.length} artifacts in message #${msg.index}`);
// Find artifact blocks in this DOM container
const artifactBlocks = findElements(SELECTORS.artifactBlocks, container);
console.log(`[Claude Timestamps] 📦 Found ${artifactBlocks.length} artifact blocks in DOM for message #${msg.index}`);
// Match artifacts to blocks (in order)
artifacts.forEach((artifact, artifactIndex) => {
if (artifactIndex < artifactBlocks.length) {
const command = artifact.input.command;
const canceled = msg.stop_reason === 'user_canceled';
console.log(`[Claude Timestamps] ✅ Injecting command "${command}" for artifact #${artifactIndex} in message #${msg.index}`);
injectArtifactMetadata(artifactBlocks[artifactIndex], command, canceled);
}
});
}
}
});
// Get unique branch count for logging
const branches = new Set();
messageToBranch.forEach(info => branches.add(info.branchIndex));
// console.log(`[Claude Timestamps] 📊 Injected timestamps for ${Math.min(activeBranch.length, messageContainers.length)} messages`);
console.log(`[Claude Timestamps] 📊 Main branch UUIDs (from max index): ${mainBranchUuids.size} messages`);
console.log(`[Claude Timestamps] 📊 Total branches found: ${branches.size}`);
return true;
} catch (error) {
console.error('[Claude Timestamps] ❌ Error:', error);
return false;
}
}
// =============================================
// DEBOUNCE
// =============================================
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// =============================================
// INITIALIZATION
// =============================================
function init() {
// console.log('[Claude Timestamps] 🚀 Initializing...');
// Initial injection
setTimeout(() => {
console.log('[Claude Timestamps] 🚀 Initial injection after page load');
injectTimestamps();
}, 1000);
// Watch for URL changes
let lastUrl = location.href;
new MutationObserver(() => {
const url = location.href;
if (url !== lastUrl) {
lastUrl = url;
console.log('[Claude Timestamps] 🔗 URL changed, re-injecting...');
clearMetadata();
setTimeout(() => injectTimestamps(), 1500);
}
}).observe(document, { subtree: true, childList: true });
// Watch for new user messages (they appear immediately when sent)
let lastMessageCount = 0;
const userMessageObserver = new MutationObserver(debounce(() => {
const messageContainers = document.querySelectorAll(
'.flex-1.flex-col.gap-3 > div[data-test-render-count]'
);
// If new messages appeared, inject timestamps
if (messageContainers.length > lastMessageCount) {
console.log('[Claude Timestamps] 📨 New user message detected, updating...');
lastMessageCount = messageContainers.length;
setTimeout(() => injectTimestamps(), 300);
}
}, 200));
// Observe message list for new messages
const observeMessages = () => {
const messageList = document.querySelector('.flex-1.flex-col.gap-3');
if (messageList) {
userMessageObserver.observe(messageList, {
childList: true,
subtree: false
});
// Initialize message count
lastMessageCount = messageList.querySelectorAll('div[data-test-render-count]').length;
} else {
// Retry if container not found
setTimeout(observeMessages, 500);
}
};
setTimeout(observeMessages, 1000);
// Watch for streaming completion
const streamingObserver = new MutationObserver(debounce((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' &&
mutation.attributeName === 'data-is-streaming') {
const target = mutation.target;
const isStreaming = target.getAttribute('data-is-streaming');
// When streaming completes
if (isStreaming === 'false') {
console.log('[Claude Timestamps] ✅ Streaming completed, updating metadata...');
setTimeout(() => injectTimestamps(), 500);
}
}
}
}, 300));
// Start observing for streaming changes
const observeStreaming = () => {
const messageContainers = document.querySelectorAll('[data-is-streaming]');
messageContainers.forEach(container => {
streamingObserver.observe(container, {
attributes: true,
attributeFilter: ['data-is-streaming']
});
});
};
// Initial observation
setTimeout(observeStreaming, 1000);
// Re-observe when new messages appear
new MutationObserver(debounce(() => {
observeStreaming();
}, 500)).observe(document.body, {
childList: true,
subtree: true
});
// Watch for version switching clicks
document.addEventListener('click', debounce((e) => {
const target = e.target.closest('button');
if (target) {
// Check if it's a version navigation button
const hasArrowIcon = findElements(SELECTORS.navigationButtons);
if (hasArrowIcon) {
console.log('[Claude Timestamps] 🔄 Version switch detected');
setTimeout(() => injectTimestamps(), 500);
}
}
}, 100), true);
// Watch for edit form changes
let editFormPresent = false;
const editModeObserver = new MutationObserver(debounce(() => {
const hasEditForm = findElements(SELECTORS.editForm) !== null;
// Edit form disappeared (user saved/canceled)
if (editFormPresent && !hasEditForm) {
console.log('[Claude Timestamps] 💾 Edit completed, restoring metadata...');
setTimeout(() => injectTimestamps(), 200);
}
editFormPresent = hasEditForm;
}, 100));
editModeObserver.observe(document.body, {
childList: true,
subtree: true
});
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();