// ==UserScript==
// @name Claude Message Info
// @namespace http://tampermonkey.net/
// @version 0.0.13
// @description Add additional metadata to Claude messages: index, branch, timestamp, UUID
// @author MRL
// @match https://claude.ai/*
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// =============================================
// 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 response = await fetch(
`/api/organizations/${orgId}/chat_conversations/${conversationId}?tree=true&rendering_mode=messages&render_all_tools=true`
);
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
return await response.json();
}
// =============================================
// 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 findMainBranchPathFromLeaf(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 findMainBranchPathFromMaxIndex(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 branches = [];
let branchCounter = 0;
// Find main path from MAX INDEX (for determining Main/Side)
const mainBranchPath = findMainBranchPathFromMaxIndex(tree);
const mainBranchUuids = new Set(mainBranchPath.map(msg => msg.uuid));
function traverseBranches(node, currentPath = [], branchStartIndex = 0) {
const newPath = [...currentPath, node];
if (node.children.length === 0) {
// Leaf node - this is a complete branch
branchCounter++;
// Check if this branch is main
const isMainBranch = newPath.every(msg => mainBranchUuids.has(msg.uuid)) &&
newPath.length === mainBranchPath.length;
branches.push({
branchId: node.uuid,
branchIndex: branchCounter,
fullPath: newPath,
branchStartIndex: branchStartIndex,
isMainBranch: isMainBranch
});
} else if (node.children.length === 1) {
// Single child - continue same branch
traverseBranches(node.children[0], newPath, branchStartIndex);
} else {
// Multiple children - branch point
node.children.forEach((child, childIndex) => {
// For first child, continue current branch
// For other children, start new branches from this point
const newBranchStart = childIndex === 0 ? branchStartIndex : newPath.length;
traverseBranches(child, newPath, newBranchStart);
});
}
}
tree.rootMessages.forEach(root => {
traverseBranches(root, [], 0);
});
return {
branches,
mainBranchUuids: mainBranchUuids // Based on MAX INDEX
};
}
/**
* Creates a Map of messageUuid to branch info
*/
function createMessageBranchMap(branches) {
const messageBranchMap = new Map();
branches.forEach(branch => {
branch.fullPath.forEach(msg => {
if (!messageBranchMap.has(msg.uuid)) {
messageBranchMap.set(msg.uuid, {
branchIndex: branch.branchIndex,
isMainBranch: branch.isMainBranch
});
}
});
});
return messageBranchMap;
}
// =============================================
// 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');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
/**
* Injects metadata into a DOM element
*/
function injectMetadata(container, messageData, mainBranchUuids, messageBranchMap, domPosition, totalDomMessages) {
// Check if already added
if (container.querySelector('.claude-timestamp-metadata')) {
return;
}
// 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 isMain = mainBranchUuids.has(messageData.uuid);
const branchStatus = isMain ? 'Main' : 'Side';
// Get branch number
const branchInfo = messageBranchMap.get(messageData.uuid);
const branchNumber = branchInfo ? branchInfo.branchIndex : '?';
// 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: -1;
pointer-events: none;
user-select: none;
white-space: nowrap;
`;
const timestamp = formatTimestamp(messageData.created_at);
metadata.textContent = `[${domPosition}/${totalDomMessages}] #${messageData.index} | Branch ${branchNumber} | ${branchStatus}${canceledText} | ${timestamp}`;
const tooltipLines = [
`DOM Position: ${domPosition} of ${totalDomMessages}`,
`API Index: ${messageData.index}`,
`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 = container.querySelector('.group.relative');
if (groupDiv) {
groupDiv.style.position = 'relative';
groupDiv.appendChild(metadata);
}
}
/**
* Removes all existing metadata badges
*/
function clearMetadata() {
document.querySelectorAll('.claude-timestamp-metadata').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 { branches, mainBranchUuids } = getAllBranchInfo(tree);
// Create message to branch mapping
const messageBranchMap = createMessageBranchMap(branches);
// Find active branch from current_leaf_message_uuid (for DOM matching)
const activeBranch = findMainBranchPathFromLeaf(tree, conversationData.current_leaf_message_uuid);
if (activeBranch.length === 0) {
console.log('[Claude Timestamps] ❌ No active branch found');
return false;
}
// Get DOM elements
const messageContainers = document.querySelectorAll(
'.flex-1.flex-col.gap-3 > div[data-test-render-count]'
);
if (messageContainers.length === 0) {
// Retry if DOM not ready yet (max 3 attempts)
if (retryCount < 3) {
console.log(`[Claude Timestamps] ⏳ DOM not ready, retrying... (${retryCount + 1}/3)`);
setTimeout(() => injectTimestamps(retryCount + 1), 500);
return false;
} else {
console.log('[Claude Timestamps] ❌ No message containers found in DOM after 3 retries');
return false;
}
}
console.log(`[Claude Timestamps] 📊 API (current_leaf): ${activeBranch.length} messages, DOM: ${messageContainers.length} elements`);
// Clear old metadata
clearMetadata();
// Match and inject
const totalDomMessages = messageContainers.length;
activeBranch.forEach((msg, index) => {
if (index < messageContainers.length) {
injectMetadata(
messageContainers[index],
msg,
mainBranchUuids,
messageBranchMap,
index + 1,
totalDomMessages
);
}
});
// console.log(`[Claude Timestamps] 📊 Injected timestamps for ${Math.min(activeBranch.length, messageContainers.length)} messages`);
// console.log(`[Claude Timestamps] 📊 Active branch (from current_leaf): ${activeBranch.length} messages`);
console.log(`[Claude Timestamps] 📊 Main branch UUIDs (from max index): ${mainBranchUuids.size} messages`);
console.log(`[Claude Timestamps] 📊 Total branches found: ${branches.length}`);
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 with API interception...');
// 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
});
}
};
setTimeout(observeMessages, 1000);
// Watch for streaming completion (data-is-streaming attribute changes)
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 (changes to "false")
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 = target.querySelector('svg path[d*="M13.2402"]') ||
target.querySelector('svg path[d*="M6.13378"]');
if (hasArrowIcon) {
console.log('[Claude Timestamps] 🔄 Version switch detected');
setTimeout(() => injectTimestamps(), 500);
}
}
}, 100), true);
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();