Claude Message Info

Add additional metadata to Claude messages: index, branch, timestamp, UUID

// ==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();
    }

})();