LMArena | Floating Copy Buttons

Adds floating copy buttons for code blocks (centered) and chat messages (beside/overlay)

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         LMArena | Floating Copy Buttons
// @namespace    https://greasyfork.org/en/users/1462137-piknockyou
// @version      1.3
// @author       Piknockyou (vibe-coded)
// @license      AGPL-3.0
// @description  Adds floating copy buttons for code blocks (centered) and chat messages (beside/overlay)
// @match        *://*lmarena.ai/*
// @icon         https://lmarena.ai/favicon.ico
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // ═══════════════════════════════════════════════════════════════════════
    // CONFIGURATION
    // ═══════════════════════════════════════════════════════════════════════

    const CONFIG = {
        // Code block button (centered on code)
        codeBlock: {
            size: 60,
            padding: 6,
            borderRadius: 14,
            iconSize: 28,
            idle: {
                background: 'rgba(255,255,255,0.08)',
                border: 'rgba(255,255,255,0.15)',
                color: 'rgba(255,255,255,0.35)'
            },
            hover: {
                background: 'rgba(30,30,30,0.95)',
                border: 'rgba(255,255,255,0.4)',
                color: '#ffffff'
            }
        },

        // Message buttons (beside message, fallback to overlay)
        message: {
            size: 40,
            padding: 8,
            borderRadius: 10,
            iconSize: 18,
            offset: 10, // Gap between button and message edge when beside
            overlayPadding: 8, // Padding when overlaying on message
            ai: {
                side: 'right',
                idle: {
                    background: 'rgba(139,92,246,0.08)',
                    border: 'rgba(139,92,246,0.25)',
                    color: 'rgba(139,92,246,0.5)'
                },
                hover: {
                    background: 'rgba(109,72,206,0.95)',
                    border: 'rgba(167,139,250,0.6)',
                    color: '#ffffff'
                }
            },
            user: {
                side: 'left',
                idle: {
                    background: 'rgba(59,130,246,0.08)',
                    border: 'rgba(59,130,246,0.25)',
                    color: 'rgba(59,130,246,0.5)'
                },
                hover: {
                    background: 'rgba(37,99,235,0.95)',
                    border: 'rgba(96,165,250,0.6)',
                    color: '#ffffff'
                }
            }
        },

        // Shared copied state
        copied: {
            background: 'rgba(34,197,94,0.95)',
            border: '#22c55e',
            color: '#ffffff'
        },

        // Safe zone padding from header/footer
        safeZonePadding: 8,

        // Hold duration threshold for drag mode (milliseconds)
        holdThreshold: 300
    };

    // ═══════════════════════════════════════════════════════════════════════
    // STATE
    // ═══════════════════════════════════════════════════════════════════════

    const buttonMap = new Map();

    // ═══════════════════════════════════════════════════════════════════════
    // UTILITIES
    // ═══════════════════════════════════════════════════════════════════════

    function copyText(text) {
        if (navigator.clipboard?.writeText) {
            return navigator.clipboard.writeText(text);
        }
        const ta = document.createElement('textarea');
        ta.value = text;
        ta.style.cssText = 'position:fixed;opacity:0;pointer-events:none';
        document.body.appendChild(ta);
        ta.select();
        document.execCommand('copy');
        ta.remove();
        return Promise.resolve();
    }

    // Find the native copy button within a message element (not code block buttons)
    function findNativeCopyButton(el) {
        // Find all copy icons in the element
        const copyIcons = el.querySelectorAll('svg.lucide-copy');

        for (const icon of copyIcons) {
            const button = icon.closest('button');
            if (!button) continue;

            // Skip if this button is inside a code block
            if (button.closest('[data-code-block="true"]')) continue;

            return button;
        }

        return null;
    }

    // Get the safe zone (area excluding header and input area)
    function getSafeZone() {
        const padding = CONFIG.safeZonePadding;

        // Find header - the border-b element in chat area
        const header = document.querySelector('#chat-area > .flex-shrink-0.border-b') ||
                       document.querySelector('#chat-area > div:first-child');

        // Find input area container - the form wrapper at the bottom
        const inputContainer = document.querySelector('.relative.flex.flex-col.items-center.pb-6') ||
                              document.querySelector('form')?.closest('div[class*="pb-"]');

        let safeTop = padding;
        let safeBottom = window.innerHeight - padding;

        if (header) {
            const headerRect = header.getBoundingClientRect();
            safeTop = Math.max(safeTop, headerRect.bottom + padding);
        }

        if (inputContainer) {
            const inputRect = inputContainer.getBoundingClientRect();
            safeBottom = Math.min(safeBottom, inputRect.top - padding);
        }

        return { safeTop, safeBottom };
    }

    function getVisibleBounds(el, safeZone) {
        const rect = el.getBoundingClientRect();
        const { safeTop, safeBottom } = safeZone;
        const vw = window.innerWidth;

        // Intersect element rect with safe zone
        const top = Math.max(rect.top, safeTop);
        const bottom = Math.min(rect.bottom, safeBottom);
        const left = Math.max(rect.left, 0);
        const right = Math.min(rect.right, vw);

        // No visible area
        if (top >= bottom || left >= right) return null;

        return {
            top, bottom, left, right,
            width: right - left,
            height: bottom - top,
            fullRect: rect
        };
    }

    function createButton(size, iconSize, className) {
        // Using div instead of button - buttons have browser-specific drag issues
        const btn = document.createElement('div');
        btn.className = `fcb-btn ${className}`;
        btn.setAttribute('role', 'button');
        btn.setAttribute('tabindex', '0');
        btn.setAttribute('draggable', 'true');
        btn.innerHTML = `
            <svg class="icon icon-copy" xmlns="http://www.w3.org/2000/svg" width="${iconSize}" height="${iconSize}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                <rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
                <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
            </svg>
            <svg class="icon icon-check" xmlns="http://www.w3.org/2000/svg" width="${iconSize}" height="${iconSize}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                <path d="M20 6 9 17l-5-5"></path>
            </svg>
        `;
        document.body.appendChild(btn);
        return btn;
    }

    function showCopiedFeedback(btn) {
        btn.classList.add('copied');
        setTimeout(() => btn.classList.remove('copied'), 2000);
    }

    function hideButton(btn) {
        btn.style.opacity = '0';
        btn.style.visibility = 'hidden';
    }

    function showButton(btn, x, y) {
        btn.style.left = `${x}px`;
        btn.style.top = `${y}px`;
        btn.style.opacity = '1';
        btn.style.visibility = 'visible';
    }

    function clampY(y, bounds, size, padding) {
        const minY = bounds.top + padding;
        const maxY = bounds.bottom - size - padding;

        // Not enough vertical space
        if (minY > maxY) return null;

        return Math.max(minY, Math.min(maxY, y));
    }

    // ═══════════════════════════════════════════════════════════════════════
    // POSITIONING STRATEGIES
    // ═══════════════════════════════════════════════════════════════════════

    function positionCentered(btn, targetEl, size, padding) {
        const safeZone = getSafeZone();
        const minVisible = size + padding * 2;
        const bounds = getVisibleBounds(targetEl, safeZone);

        if (!bounds || bounds.height < minVisible) {
            return hideButton(btn);
        }

        let x = bounds.left + (bounds.width / 2) - (size / 2);
        let y = bounds.top + (bounds.height / 2) - (size / 2);

        const clampedY = clampY(y, bounds, size, padding);
        if (clampedY === null) {
            return hideButton(btn);
        }

        // Clamp X within visible bounds
        x = Math.max(bounds.left + padding, Math.min(bounds.right - size - padding, x));

        showButton(btn, x, clampedY);
    }

    function positionBeside(btn, targetEl, size, padding, side, offset) {
        const safeZone = getSafeZone();
        const minVisible = size + padding * 2;
        const bounds = getVisibleBounds(targetEl, safeZone);

        if (!bounds || bounds.height < minVisible) {
            return hideButton(btn);
        }

        // Calculate "beside" position
        const besideX = side === 'left'
            ? bounds.fullRect.left - size - offset
            : bounds.fullRect.right + offset;

        // Check if "beside" position fits within viewport
        const fitsBeside = besideX >= padding && besideX + size <= window.innerWidth - padding;

        let x;

        if (fitsBeside) {
            // Position beside the message
            x = besideX;
        } else {
            // Fallback: overlay on the message
            const overlayPadding = CONFIG.message.overlayPadding;

            if (side === 'left') {
                // For user messages (left side), overlay on left edge
                x = bounds.left + overlayPadding;
            } else {
                // For AI messages (right side), overlay on right edge
                x = bounds.right - size - overlayPadding;
            }

            // Ensure x stays within viewport
            x = Math.max(padding, Math.min(window.innerWidth - size - padding, x));
        }

        // Calculate Y position (vertically centered within visible bounds)
        let y = bounds.top + (bounds.height / 2) - (size / 2);
        const clampedY = clampY(y, bounds, size, padding);

        if (clampedY === null) {
            return hideButton(btn);
        }

        showButton(btn, x, clampedY);
    }

    // ═══════════════════════════════════════════════════════════════════════
    // STYLES
    // ═══════════════════════════════════════════════════════════════════════

    function injectStyles() {
        if (document.getElementById('fcb-styles')) return;

        const { codeBlock: cb, message: msg, copied } = CONFIG;

        const style = document.createElement('style');
        style.id = 'fcb-styles';
        style.textContent = `
            .fcb-btn {
                position: fixed;
                z-index: 10000;
                cursor: pointer;
                display: flex;
                align-items: center;
                justify-content: center;
                transition: all 0.2s ease;
                opacity: 0;
                visibility: hidden;
                backdrop-filter: blur(8px);
                -webkit-backdrop-filter: blur(8px);
                pointer-events: auto;
                -webkit-user-drag: element;
                -webkit-user-select: none;
                user-select: none;
                -moz-user-select: none;
                touch-action: none;
            }
            .fcb-btn * {
                pointer-events: none !important;
                -webkit-user-drag: none !important;
            }
            .fcb-btn .icon {
                position: absolute;
                transition: opacity 0.2s, transform 0.2s;
            }
            .fcb-btn.holding {
                transform: scale(0.92);
                filter: brightness(0.85);
            }
            .fcb-btn.dragging {
                opacity: 0.6 !important;
                cursor: grabbing;
            }
            .fcb-btn .icon-copy { opacity: 1; transform: scale(1); }
            .fcb-btn .icon-check { opacity: 0; transform: scale(0.5); }
            .fcb-btn.copied .icon-copy { opacity: 0; transform: scale(0.5); }
            .fcb-btn.copied .icon-check { opacity: 1; transform: scale(1); }
            .fcb-btn.copied {
                background: ${copied.background} !important;
                color: ${copied.color} !important;
                border-color: ${copied.border} !important;
            }

            /* Code block */
            .fcb-code {
                width: ${cb.size}px;
                height: ${cb.size}px;
                border-radius: ${cb.borderRadius}px;
                border: 2px solid ${cb.idle.border};
                background: ${cb.idle.background};
                color: ${cb.idle.color};
            }
            .fcb-code:hover {
                background: ${cb.hover.background};
                color: ${cb.hover.color};
                border-color: ${cb.hover.border};
                transform: scale(1.08);
                box-shadow: 0 8px 32px rgba(0,0,0,0.4);
            }

            /* AI message */
            .fcb-ai {
                width: ${msg.size}px;
                height: ${msg.size}px;
                border-radius: ${msg.borderRadius}px;
                border: 2px solid ${msg.ai.idle.border};
                background: ${msg.ai.idle.background};
                color: ${msg.ai.idle.color};
            }
            .fcb-ai:hover {
                background: ${msg.ai.hover.background};
                color: ${msg.ai.hover.color};
                border-color: ${msg.ai.hover.border};
                transform: scale(1.1);
                box-shadow: 0 6px 24px rgba(0,0,0,0.4);
            }

            /* User message */
            .fcb-user {
                width: ${msg.size}px;
                height: ${msg.size}px;
                border-radius: ${msg.borderRadius}px;
                border: 2px solid ${msg.user.idle.border};
                background: ${msg.user.idle.background};
                color: ${msg.user.idle.color};
            }
            .fcb-user:hover {
                background: ${msg.user.hover.background};
                color: ${msg.user.hover.color};
                border-color: ${msg.user.hover.border};
                transform: scale(1.1);
                box-shadow: 0 6px 24px rgba(0,0,0,0.4);
            }
        `;
        document.head.appendChild(style);
    }

    // ═══════════════════════════════════════════════════════════════════════
    // ELEMENT DETECTION
    // ═══════════════════════════════════════════════════════════════════════

    function isAIMessage(el) {
        return el.tagName === 'DIV' &&
               el.classList.contains('bg-surface-primary') &&
               el.querySelector('.sticky .font-mono, .sticky.top-0');
    }

    function isUserMessage(el) {
        return el.tagName === 'DIV' &&
               el.classList.contains('self-end') &&
               el.querySelector('.bg-surface-secondary');
    }

    // ═══════════════════════════════════════════════════════════════════════
    // CONTENT EXTRACTION
    // ═══════════════════════════════════════════════════════════════════════

    function getCodeContent(block) {
        const code = block.querySelector('code');
        return code?.textContent || '';
    }

    function getMessageContent(el, isUser) {
        const selector = isUser
            ? '.bg-surface-secondary .prose'
            : '.no-scrollbar .prose';
        const prose = el.querySelector(selector);
        return prose?.textContent?.trim() || '';
    }

    // Convert prose HTML to Markdown for drag operations
    function getMessageMarkdown(el, isUser) {
        const selector = isUser
            ? '.bg-surface-secondary .prose'
            : '.no-scrollbar .prose';
        const prose = el.querySelector(selector);
        if (!prose) return '';
        return htmlToMarkdown(prose);
    }

    // HTML to Markdown converter
    function htmlToMarkdown(element) {
        if (!element) return '';

        function processNode(node) {
            if (node.nodeType === Node.TEXT_NODE) {
                return node.textContent;
            }
            if (node.nodeType !== Node.ELEMENT_NODE) {
                return '';
            }

            const tag = node.tagName.toLowerCase();
            const children = Array.from(node.childNodes).map(processNode).join('');

            switch (tag) {
                case 'h1': return `# ${children.trim()}\n\n`;
                case 'h2': return `## ${children.trim()}\n\n`;
                case 'h3': return `### ${children.trim()}\n\n`;
                case 'h4': return `#### ${children.trim()}\n\n`;
                case 'h5': return `##### ${children.trim()}\n\n`;
                case 'h6': return `###### ${children.trim()}\n\n`;
                case 'p': return `${children}\n\n`;
                case 'br': return '\n';
                case 'strong':
                case 'b': return `**${children}**`;
                case 'em':
                case 'i': return `*${children}*`;
                case 'code':
                    if (node.closest('pre')) return children;
                    return `\`${children}\``;
                case 'pre':
                    const codeEl = node.querySelector('code');
                    const langMatch = codeEl?.className?.match(/language-(\w+)/);
                    const lang = langMatch ? langMatch[1] : '';
                    const codeContent = codeEl?.textContent || children;
                    return `\`\`\`${lang}\n${codeContent}\n\`\`\`\n\n`;
                case 'a':
                    const href = node.getAttribute('href') || '';
                    return `[${children}](${href})`;
                case 'ul':
                case 'ol':
                    return children + '\n';
                case 'li':
                    const parent = node.parentElement;
                    const isOrdered = parent?.tagName.toLowerCase() === 'ol';
                    if (isOrdered) {
                        const idx = Array.from(parent.children).indexOf(node) + 1;
                        return `${idx}. ${children.trim()}\n`;
                    }
                    return `- ${children.trim()}\n`;
                case 'blockquote':
                    return children.trim().split('\n').map(l => `> ${l}`).join('\n') + '\n\n';
                case 'hr': return '\n---\n\n';
                case 'table':
                    return processTable(node);
                default:
                    return children;
            }
        }

        function processTable(table) {
            const rows = Array.from(table.querySelectorAll('tr'));
            if (rows.length === 0) return '';

            let md = '';
            rows.forEach((row, rowIdx) => {
                const cells = Array.from(row.querySelectorAll('th, td'));
                const cellTexts = cells.map(c => c.textContent.trim());
                md += '| ' + cellTexts.join(' | ') + ' |\n';
                if (rowIdx === 0) {
                    md += '| ' + cells.map(() => '---').join(' | ') + ' |\n';
                }
            });
            return md + '\n';
        }

        const result = processNode(element);
        return result.replace(/\n{3,}/g, '\n\n').trim();
    }

    // ═══════════════════════════════════════════════════════════════════════
    // SETUP FUNCTIONS
    // ═══════════════════════════════════════════════════════════════════════

    function setupCodeBlock(block) {
        if (buttonMap.has(block)) return;

        const { size, padding, iconSize } = CONFIG.codeBlock;
        const container = block.querySelector('.code-block_container__lbMX4, pre') || block;
        const btn = createButton(size, iconSize, 'fcb-code');

        const data = {
            btn,
            type: 'code',
            target: container,
            update: () => positionCentered(btn, container, size, padding),
            getContent: () => getCodeContent(block)
        };

        buttonMap.set(block, data);
        setupDragAndClick(btn, data);
        data.update();
    }

    function setupMessage(el, isUser) {
        if (buttonMap.has(el)) return;

        const { size, padding, iconSize, offset } = CONFIG.message;
        const msgConfig = isUser ? CONFIG.message.user : CONFIG.message.ai;
        const className = isUser ? 'fcb-user' : 'fcb-ai';

        const target = isUser
            ? el.querySelector('.bg-surface-secondary') || el
            : el.querySelector('.no-scrollbar') || el;

        const btn = createButton(size, iconSize, className);

        const data = {
            btn,
            type: isUser ? 'user' : 'ai',
            target,
            sourceEl: el,
            update: () => positionBeside(btn, target, size, padding, msgConfig.side, offset),
            getContent: () => getMessageContent(el, isUser),
            getDragContent: () => getMessageMarkdown(el, isUser),
            getNativeButton: () => findNativeCopyButton(el)
        };

        buttonMap.set(el, data);
        setupDragAndClick(btn, data);
        data.update();
    }

    function setupDragAndClick(btn, data) {
        let mouseDownTime = 0;
        let mouseDownPos = { x: 0, y: 0 };
        let dragDidStart = false;
        let holdTimer = null;
        let isMouseDown = false;
        let moveCount = 0;

        const dbg = `[FCB:${data.type}]`;

        const cleanup = () => {
            clearTimeout(holdTimer);
            holdTimer = null;
            isMouseDown = false;
            moveCount = 0;
            btn.classList.remove('holding');
        };

        console.log(dbg, 'Button created, draggable =', btn.getAttribute('draggable'), 'tagName =', btn.tagName);

        // Track mouse movement to see if user is trying to drag
        btn.addEventListener('mousemove', (e) => {
            if (!isMouseDown) return;
            moveCount++;
            if (moveCount === 1 || moveCount === 5 || moveCount === 10) {
                const dx = Math.abs(e.clientX - mouseDownPos.x);
                const dy = Math.abs(e.clientY - mouseDownPos.y);
                console.log(dbg, `mousemove #${moveCount}, delta: ${dx.toFixed(0)}x${dy.toFixed(0)}px`);
            }
        });

        btn.addEventListener('mousedown', (e) => {
            console.log(dbg, 'mousedown, button =', e.button, 'target =', e.target.tagName);
            if (e.button !== 0) return;

            mouseDownTime = Date.now();
            mouseDownPos = { x: e.clientX, y: e.clientY };
            dragDidStart = false;
            isMouseDown = true;
            moveCount = 0;

            holdTimer = setTimeout(() => {
                console.log(dbg, 'hold threshold approaching, adding .holding class');
                btn.classList.add('holding');
            }, CONFIG.holdThreshold * 0.6);
        });

        btn.addEventListener('dragstart', (e) => {
            const holdDuration = Date.now() - mouseDownTime;
            console.log(dbg, '>>> dragstart fired! holdDuration =', holdDuration, 'threshold =', CONFIG.holdThreshold);

            if (holdDuration < CONFIG.holdThreshold) {
                console.log(dbg, 'dragstart CANCELLED (too quick)');
                e.preventDefault();
                cleanup();
                return;
            }

            // Use getDragContent for markdown if available, otherwise plain content
            const content = data.getDragContent ? data.getDragContent() : data.getContent();
            console.log(dbg, 'drag content length =', content?.length || 0);

            if (!content) {
                console.log(dbg, 'dragstart CANCELLED (no content)');
                e.preventDefault();
                cleanup();
                return;
            }

            console.log(dbg, 'Setting drag data...');
            e.dataTransfer.setData('text/plain', content);
            e.dataTransfer.effectAllowed = 'copyMove';

            // Create drag image
            const dragImage = document.createElement('div');
            dragImage.textContent = `📄 ${content.length > 50 ? content.substring(0, 47) + '...' : content}`;
            dragImage.style.cssText = `
                position: absolute;
                left: -9999px;
                top: -9999px;
                padding: 8px 12px;
                background: rgba(30, 30, 30, 0.95);
                color: #fff;
                border-radius: 6px;
                font-size: 12px;
                font-family: system-ui, sans-serif;
                max-width: 300px;
                white-space: nowrap;
                overflow: hidden;
                text-overflow: ellipsis;
                box-shadow: 0 4px 12px rgba(0,0,0,0.3);
                z-index: 99999;
            `;
            document.body.appendChild(dragImage);

            try {
                e.dataTransfer.setDragImage(dragImage, 10, 10);
                console.log(dbg, 'setDragImage called');
            } catch (err) {
                console.log(dbg, 'setDragImage error:', err);
            }

            requestAnimationFrame(() => dragImage.remove());

            dragDidStart = true;
            cleanup();
            btn.classList.add('dragging');
            console.log(dbg, 'Drag started successfully!');
        });

        btn.addEventListener('drag', (e) => {
            if (!btn._dragLogged) {
                console.log(dbg, 'drag event firing (drag in progress)');
                btn._dragLogged = true;
            }
        });

        btn.addEventListener('dragend', (e) => {
            console.log(dbg, 'dragend, dropEffect =', e.dataTransfer?.dropEffect);
            btn.classList.remove('dragging');
            btn._dragLogged = false;
            setTimeout(() => {
                dragDidStart = false;
            }, 50);
        });

        btn.addEventListener('mouseup', (e) => {
            const holdDuration = Date.now() - mouseDownTime;
            console.log(dbg, 'mouseup, holdDuration =', holdDuration, 'moveCount =', moveCount);
            cleanup();
        });

        btn.addEventListener('mouseleave', (e) => {
            if (isMouseDown) {
                console.log(dbg, 'mouseleave while mousedown, moveCount =', moveCount);
            }
            cleanup();
        });

        btn.addEventListener('click', async (e) => {
            e.preventDefault();
            e.stopPropagation();
            cleanup();

            const holdDuration = Date.now() - mouseDownTime;
            console.log(dbg, 'click, dragDidStart =', dragDidStart, 'holdDuration =', holdDuration);

            if (dragDidStart) {
                console.log(dbg, 'click IGNORED (drag occurred)');
                return;
            }

            if (holdDuration >= CONFIG.holdThreshold) {
                console.log(dbg, 'click IGNORED (held too long)');
                return;
            }

            console.log(dbg, 'Performing copy...');

            if (data.type === 'user' || data.type === 'ai') {
                const nativeBtn = data.getNativeButton?.();
                if (nativeBtn) {
                    nativeBtn.click();
                    showCopiedFeedback(btn);
                    return;
                }
            }

            const content = data.getContent();
            if (!content) return;

            try {
                await copyText(content);
                showCopiedFeedback(btn);
            } catch (err) {
                console.error('Copy failed:', err);
            }
        });
    }

    // ═══════════════════════════════════════════════════════════════════════
    // PROCESSING
    // ═══════════════════════════════════════════════════════════════════════

    function processAll() {
        // Code blocks
        document.querySelectorAll('[data-code-block="true"]').forEach(setupCodeBlock);

        // Messages
        const ol = document.querySelector('ol.flex-col-reverse');
        if (ol) {
            Array.from(ol.children).forEach(child => {
                if (isAIMessage(child)) setupMessage(child, false);
                else if (isUserMessage(child)) setupMessage(child, true);
            });
        }
    }

    function updateAll() {
        for (const [el, data] of buttonMap) {
            if (!document.body.contains(el)) {
                data.btn.remove();
                buttonMap.delete(el);
            } else {
                data.update();
            }
        }
    }

    // ═══════════════════════════════════════════════════════════════════════
    // INITIALIZATION
    // ═══════════════════════════════════════════════════════════════════════

    injectStyles();
    processAll();
    updateAll();

    // DOM observer
    new MutationObserver(() => {
        processAll();
        updateAll();
    }).observe(document.body, { childList: true, subtree: true });

    // Throttled scroll/resize
    let rafId = null;
    const onScrollResize = () => {
        if (rafId) return;
        rafId = requestAnimationFrame(() => {
            updateAll();
            rafId = null;
        });
    };

    window.addEventListener('scroll', onScrollResize, { passive: true });
    window.addEventListener('resize', onScrollResize, { passive: true });
    document.addEventListener('scroll', onScrollResize, { passive: true, capture: true });
})();