Claude Message Info

Add metadata to Claude messages: index, branch, timestamp, UUID, artifact commands

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

})();