ChatGPT | Table of Contents / TOC

Floating Table of Contents sidebar for ChatGPT conversations. Uses backend API for 100% accuracy (no lazy-loading gaps). Draggable toggle button, expandable message headers, click-to-navigate, persistent state.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         ChatGPT | Table of Contents / TOC
// @namespace    https://greasyfork.org/en/users/1462137-piknockyou
// @version      3.3
// @author       Piknockyou (vibe-coded; see credits below)
// @license      AGPL-3.0
// @description  Floating Table of Contents sidebar for ChatGPT conversations. Uses backend API for 100% accuracy (no lazy-loading gaps). Draggable toggle button, expandable message headers, click-to-navigate, persistent state.
// @match        https://chatgpt.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=chatgpt.com
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-idle
// ==/UserScript==

/*
 * CREDITS & ATTRIBUTION:
 * This Userscript is heavily inspired by the "Scroll" extension by Asker Kurtelli.
 * Original Extension: https://github.com/asker-kurtelli/scroll
 *
 * DIFFERENCE IN ARCHITECTURE:
 * While the UI concept is derived from Scroll, this script utilizes a different backend approach.
 * Instead of scraping the DOM (which relies on messages being rendered), this script fetches
 * the conversation tree directly from ChatGPT's internal `backend-api`. This ensures the
 * Table of Contents is always 100% complete and accurate, bypassing ChatGPT's lazy-loading/virtualization
 * mechanisms that often hide messages from the DOM when they are off-screen.
 */

(function () {
    'use strict';

    // =========================================================================
    // CONFIGURATION
    // =========================================================================
    const CONFIG = {
        defaultWidth: 300,
        headerOffset: 80,
        debounceDelay: 1500,
        buttonSize: 40,
        storageKeys: {
            isOpen: 'chatgpt-toc-isOpen',
            position: 'chatgpt-toc-position'
        },
        colors: {
            bg: '#171717',
            border: '#333',
            text: '#ececec',
            subText: '#999',
            hover: '#2a2a2a',
            userIcon: '#999',
            aiIcon: '#10a37f'
        },
        icons: {
            user: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>`,
            ai: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>`,
            arrowRight: `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>`,
            arrowDown: `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>`
        }
    };

    // =========================================================================
    // STATE MANAGEMENT (GM Storage)
    // =========================================================================
    function loadIsOpenState() {
        return GM_getValue(CONFIG.storageKeys.isOpen, true);
    }

    function saveIsOpenState(isOpen) {
        GM_setValue(CONFIG.storageKeys.isOpen, isOpen);
    }

    function loadPosition() {
        return GM_getValue(CONFIG.storageKeys.position, { ratioX: 1, ratioY: 0 });
    }

    function savePosition(ratioX, ratioY) {
        GM_setValue(CONFIG.storageKeys.position, { ratioX, ratioY });
    }

    const state = {
        isOpen: loadIsOpenState(),
        accessToken: null,
        elements: {},
        expandedItems: new Set(),
        cachedItems: [],
        isDragging: false
    };

    // =========================================================================
    // UI INITIALIZATION
    // =========================================================================

    // --- Position Management ---
    function setButtonPosition(btn, ratioX, ratioY) {
        const margin = 10;
        const maxX = window.innerWidth - CONFIG.buttonSize - margin;
        const maxY = window.innerHeight - CONFIG.buttonSize - margin;
        btn.style.left = `${Math.max(margin, ratioX * maxX)}px`;
        btn.style.top = `${Math.max(margin, ratioY * maxY)}px`;
    }

    function applyButtonPosition() {
        if (!state.elements.toggle) return;
        const { ratioX, ratioY } = loadPosition();
        setButtonPosition(state.elements.toggle, ratioX, ratioY);
    }

    // --- Smart Sidebar Positioning ---
    function calculateSidebarPosition(btnRect) {
        const margin = 10;
        const vw = window.innerWidth;
        const vh = window.innerHeight;
        const sidebarWidth = CONFIG.defaultWidth;
        const maxHeight = vh - 2 * margin - 20;

        // Determine if button is in left or right half
        const buttonInLeftHalf = btnRect.left < vw / 2;
        // Determine if button is in top or bottom half
        const buttonInTopHalf = btnRect.top < vh / 2;

        let left, top, height;

        // Horizontal positioning
        if (buttonInLeftHalf) {
            // Position to the right of button, or below if no space
            if (btnRect.right + margin + sidebarWidth <= vw - margin) {
                left = btnRect.right + margin;
            } else {
                left = Math.max(margin, Math.min(btnRect.left, vw - sidebarWidth - margin));
            }
        } else {
            // Position to the left of button, or below if no space
            if (btnRect.left - margin - sidebarWidth >= margin) {
                left = btnRect.left - margin - sidebarWidth;
            } else {
                left = Math.max(margin, Math.min(btnRect.right - sidebarWidth, vw - sidebarWidth - margin));
            }
        }

        // Vertical positioning
        if (buttonInTopHalf) {
            // Align top with button, extend downward
            top = btnRect.top;
            height = Math.min(maxHeight, vh - top - margin);
        } else {
            // Align bottom with button, extend upward
            const bottom = vh - btnRect.bottom;
            height = Math.min(maxHeight, vh - bottom - margin);
            top = vh - bottom - height;
        }

        // Final clamping
        top = Math.max(margin, Math.min(top, vh - 100));
        left = Math.max(margin, Math.min(left, vw - sidebarWidth - margin));
        height = Math.max(150, height);

        return { left, top, height };
    }

    function updateSidebarPosition() {
        const { toggle, sidebar } = state.elements;
        if (!toggle || !sidebar) return;

        const btnRect = toggle.getBoundingClientRect();
        const pos = calculateSidebarPosition(btnRect);

        sidebar.style.left = `${pos.left}px`;
        sidebar.style.top = `${pos.top}px`;
        sidebar.style.right = 'auto';
        sidebar.style.maxHeight = `${pos.height}px`;
    }

    // --- Drag Handling ---
    function startDrag(e, toggle) {
        state.isDragging = true;

        const rect = toggle.getBoundingClientRect();
        const offX = e.clientX - rect.left;
        const offY = e.clientY - rect.top;

        toggle.style.cursor = 'grabbing';

        const onMove = (ev) => {
            const margin = 10;
            const x = Math.max(margin, Math.min(window.innerWidth - CONFIG.buttonSize - margin, ev.clientX - offX));
            const y = Math.max(margin, Math.min(window.innerHeight - CONFIG.buttonSize - margin, ev.clientY - offY));
            toggle.style.left = `${x}px`;
            toggle.style.top = `${y}px`;
        };

        const onUp = () => {
            toggle.style.cursor = 'pointer';
            document.removeEventListener('mousemove', onMove);
            document.removeEventListener('mouseup', onUp);

            // Save final position
            const finalRect = toggle.getBoundingClientRect();
            const margin = 10;
            const maxX = window.innerWidth - CONFIG.buttonSize - margin;
            const maxY = window.innerHeight - CONFIG.buttonSize - margin;
            const rx = maxX > 0 ? (finalRect.left - margin) / maxX : 0;
            const ry = maxY > 0 ? (finalRect.top - margin) / maxY : 0;
            savePosition(Math.max(0, Math.min(1, rx)), Math.max(0, Math.min(1, ry)));

            setTimeout(() => {
                state.isDragging = false;
            }, 50);
        };

        document.addEventListener('mousemove', onMove);
        document.addEventListener('mouseup', onUp);
    }

    // --- Toggle Sidebar ---
    function toggleSidebar() {
        state.isOpen = !state.isOpen;
        const { toggle, sidebar } = state.elements;

        if (state.isOpen) {
            updateSidebarPosition();
            sidebar.style.display = 'block';
        } else {
            sidebar.style.display = 'none';
        }

        updateToggleTooltip(toggle);
        saveIsOpenState(state.isOpen);
    }

    // --- Dynamic Tooltip ---
    function updateToggleTooltip(toggle) {
        if (state.isOpen) {
            toggle.title = 'Left-click: Close TOC\n\n(Close TOC to reposition button)';
        } else {
            toggle.title = 'Left-click: Open TOC\nRight-drag: Move button';
        }
    }

    // --- Main Init ---
    function initUI() {
        const toggle = document.createElement('div');
        toggle.innerHTML = '☰';
        toggle.style.cssText = `
            position: fixed;
            width: ${CONFIG.buttonSize}px; height: ${CONFIG.buttonSize}px;
            background: ${CONFIG.colors.bg}; color: ${CONFIG.colors.text};
            border: 1px solid ${CONFIG.colors.border}; border-radius: 8px;
            z-index: 10001; display: flex; align-items: center; justify-content: center;
            cursor: pointer; font-size: 20px; user-select: none;
            box-shadow: 0 4px 12px rgba(0,0,0,0.3);
            transition: transform 0.1s, box-shadow 0.2s;
        `;

        // Apply saved position and initial tooltip
        const { ratioX, ratioY } = loadPosition();
        setButtonPosition(toggle, ratioX, ratioY);
        updateToggleTooltip(toggle);

        // Left-click: Toggle sidebar (only if not mid-drag)
        toggle.addEventListener('click', (e) => {
            if (e.button === 0 && !state.isDragging) {
                toggleSidebar();
            }
        });

        // Prevent context menu
        toggle.addEventListener('contextmenu', (e) => {
            e.preventDefault();
            e.stopPropagation();
        });

        // Right-click drag: Only when TOC is closed
        toggle.addEventListener('mousedown', (e) => {
            if (e.button === 2) {
                e.preventDefault();
                if (!state.isOpen) {
                    startDrag(e, toggle);
                }
            }
        });

        // Hover effect
        toggle.addEventListener('mouseenter', () => {
            toggle.style.transform = 'scale(1.05)';
            toggle.style.boxShadow = '0 6px 16px rgba(0,0,0,0.4)';
        });
        toggle.addEventListener('mouseleave', () => {
            toggle.style.transform = 'scale(1)';
            toggle.style.boxShadow = '0 4px 12px rgba(0,0,0,0.3)';
        });

        const sidebar = document.createElement('div');
        sidebar.style.cssText = `
            position: fixed;
            width: ${CONFIG.defaultWidth}px;
            background: ${CONFIG.colors.bg}; border: 1px solid ${CONFIG.colors.border};
            border-radius: 12px; z-index: 10000;
            display: none;
            overflow-y: auto; overflow-x: hidden;
            box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
            font-family: Söhne, ui-sans-serif, system-ui, -apple-system, sans-serif;
            font-size: 13px; color: ${CONFIG.colors.text};
            resize: horizontal; direction: rtl;
        `;

        const content = document.createElement('div');
        content.id = 'toc-content';
        content.style.cssText = 'direction: ltr; padding: 10px;';
        content.innerHTML = '<div style="padding:10px; color:#888;">Initializing...</div>';

        sidebar.appendChild(content);
        document.body.appendChild(toggle);
        document.body.appendChild(sidebar);

        state.elements = { toggle, sidebar, content };

        // Show sidebar if was open, with proper positioning
        if (state.isOpen) {
            updateSidebarPosition();
            sidebar.style.display = 'block';
        }

        // Handle window resize
        window.addEventListener('resize', () => {
            applyButtonPosition();
            if (state.isOpen) {
                updateSidebarPosition();
            }
        });
    }

    // =========================================================================
    // API LOGIC
    // =========================================================================
    async function getAccessToken() {
        try {
            const resp = await fetch('/api/auth/session');
            if (resp.ok) {
                const data = await resp.json();
                return data.accessToken;
            }
        } catch (e) {
            // Silent fail
        }
        return null;
    }

    function getUUID() {
        const match = window.location.pathname.match(/\/c\/([a-f0-9-]{36})/);
        return match ? match[1] : null;
    }

    async function loadConversation() {
        const contentDiv = state.elements.content;
        const uuid = getUUID();

        if (!uuid) {
            contentDiv.innerHTML = '<div style="padding:10px; color:#aaa;">No Conversation ID.</div>';
            return;
        }

        if (!state.accessToken) {
            state.accessToken = await getAccessToken();
        }
        if (!state.accessToken) return;

        // Only show loading on initial load
        if (!contentDiv.textContent.includes('TOC (')) {
            contentDiv.innerHTML = '<div style="padding:10px; color:#aaa;">Loading...</div>';
        }

        try {
            const response = await fetch(`/backend-api/conversation/${uuid}`, {
                headers: {
                    'Authorization': `Bearer ${state.accessToken}`,
                    'Content-Type': 'application/json'
                }
            });

            if (!response.ok) throw new Error(`API Error: ${response.status}`);
            const data = await response.json();
            processData(data);
        } catch (err) {
            contentDiv.innerHTML = `<div style="padding:10px; color:#f87171;">Error: ${err.message}</div>`;
        }
    }

    // =========================================================================
    // DATA PROCESSING
    // =========================================================================
    function extractHeaders(text) {
        const headers = [];
        const lines = text.split('\n');

        for (const line of lines) {
            const trimmed = line.trim();
            if (!trimmed) continue;

            // Markdown headers (# Header)
            const mdMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
            if (mdMatch) {
                headers.push({
                    type: 'markdown',
                    text: mdMatch[2].trim(),
                    level: mdMatch[1].length
                });
                continue;
            }

            // Bold headers (**Header** or __Header__)
            const boldMatch = trimmed.match(/^(\*\*|__)(.+?)\1:?$/);
            if (boldMatch) {
                headers.push({
                    type: 'bold',
                    text: boldMatch[2].trim(),
                    level: 3
                });
            }
        }
        return headers;
    }

    function processData(data) {
        if (!data.mapping || !data.current_node) return;

        const thread = [];
        let currId = data.current_node;

        while (currId) {
            const node = data.mapping[currId];
            if (!node) break;

            const msg = node.message;
            if (msg?.content?.parts?.length > 0) {
                const isSystem = msg.author.role === 'system';
                const isInternal = msg.recipient && msg.recipient !== 'all';

                if (!isSystem && !isInternal) {
                    let text = '';
                    if (typeof msg.content.parts[0] === 'string') {
                        text = msg.content.parts[0];
                    } else if (msg.content.content_type === 'code') {
                        text = 'Code Block';
                    }

                    if (text.trim()) {
                        thread.push({
                            id: msg.id,
                            role: msg.author.role,
                            text: text,
                            headers: extractHeaders(text)
                        });
                    }
                }
            }
            currId = node.parent;
        }

        thread.reverse();
        state.cachedItems = thread;
        renderTOC(thread);
    }

    // =========================================================================
    // RENDERING
    // =========================================================================
    function renderTOC(items) {
        const container = document.createElement('div');

        // Header
        const header = document.createElement('div');
        header.style.cssText = 'padding-bottom:10px; border-bottom:1px solid #333; margin-bottom:10px; font-weight:700; font-size:14px; color:#fff;';
        header.textContent = `TOC (${items.length})`;
        container.appendChild(header);

        // Items
        items.forEach(item => {
            const hasHeaders = item.headers.length > 0;
            const isExpanded = state.expandedItems.has(item.id);
            const isUser = item.role === 'user';

            // Main row
            const row = document.createElement('div');
            row.style.cssText = `
                padding: 6px 4px; border-radius: 6px; margin-bottom: 2px;
                display: flex; align-items: center; gap: 6px;
                cursor: pointer; transition: background 0.1s; min-width: 0;
            `;
            row.onmouseenter = () => row.style.background = CONFIG.colors.hover;
            row.onmouseleave = () => row.style.background = 'transparent';
            row.onclick = () => scrollToMessage(item.id);

            // Arrow
            const arrowBox = document.createElement('div');
            arrowBox.style.cssText = `
                width: 16px; height: 16px; flex-shrink: 0;
                display: flex; align-items: center; justify-content: center;
                color: ${CONFIG.colors.subText};
            `;
            if (hasHeaders) {
                arrowBox.innerHTML = isExpanded ? CONFIG.icons.arrowDown : CONFIG.icons.arrowRight;
                arrowBox.onclick = (e) => {
                    e.stopPropagation();
                    toggleExpand(item.id);
                };
                arrowBox.onmouseenter = () => arrowBox.style.color = '#fff';
                arrowBox.onmouseleave = () => arrowBox.style.color = CONFIG.colors.subText;
            }
            row.appendChild(arrowBox);

            // Role icon
            const iconBox = document.createElement('div');
            iconBox.style.cssText = `
                width: 16px; height: 16px; flex-shrink: 0;
                color: ${isUser ? CONFIG.colors.userIcon : CONFIG.colors.aiIcon};
            `;
            iconBox.innerHTML = isUser ? CONFIG.icons.user : CONFIG.icons.ai;
            row.appendChild(iconBox);

            // Title
            const titleSpan = document.createElement('span');
            const cleanTitle = item.text.split('\n')[0].replace(/[#*`_]/g, '').trim() || 'Message';
            titleSpan.textContent = cleanTitle;
            titleSpan.style.cssText = `
                flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
                font-weight: ${isUser ? '400' : '500'};
                color: ${isUser ? '#bbb' : '#fff'};
                font-size: 13px;
            `;
            row.appendChild(titleSpan);

            container.appendChild(row);

            // Subheaders
            if (hasHeaders && isExpanded) {
                const subContainer = document.createElement('div');
                subContainer.style.cssText = `
                    margin-left: 22px; border-left: 1px solid ${CONFIG.colors.border};
                    padding-left: 4px; margin-bottom: 4px;
                `;

                item.headers.forEach(h => {
                    const subRow = document.createElement('div');
                    subRow.textContent = h.text;
                    subRow.title = h.text;
                    subRow.style.cssText = `
                        padding: 4px 8px; cursor: pointer; font-size: 12px;
                        color: ${CONFIG.colors.subText};
                        white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
                        border-radius: 4px;
                    `;
                    subRow.onmouseenter = () => {
                        subRow.style.color = CONFIG.colors.text;
                        subRow.style.background = CONFIG.colors.hover;
                    };
                    subRow.onmouseleave = () => {
                        subRow.style.color = CONFIG.colors.subText;
                        subRow.style.background = 'transparent';
                    };
                    subRow.onclick = (e) => {
                        e.stopPropagation();
                        scrollToHeader(item.id, h.text);
                    };
                    subContainer.appendChild(subRow);
                });

                container.appendChild(subContainer);
            }
        });

        state.elements.content.innerHTML = '';
        state.elements.content.appendChild(container);
    }

    function toggleExpand(id) {
        if (state.expandedItems.has(id)) {
            state.expandedItems.delete(id);
        } else {
            state.expandedItems.add(id);
        }
        renderTOC(state.cachedItems);
    }

    // =========================================================================
    // NAVIGATION
    // =========================================================================
    function scrollToMessage(messageId) {
        const el = document.querySelector(`[data-message-id="${messageId}"]`);
        if (el) {
            smartScrollTo(el);
            flashElement(el);
        }
    }

    function scrollToHeader(messageId, headerText) {
        const messageEl = document.querySelector(`[data-message-id="${messageId}"]`);
        if (!messageEl) return;

        const candidates = messageEl.querySelectorAll('h1, h2, h3, h4, h5, h6, strong, b');
        const target = Array.from(candidates).find(el => {
            const elText = el.innerText.trim();
            return elText && (elText.includes(headerText) || headerText.includes(elText));
        });

        if (target) {
            smartScrollTo(target);
            flashElement(target);
        } else {
            smartScrollTo(messageEl);
            flashElement(messageEl);
        }
    }

    function smartScrollTo(element) {
        let scrollContainer = element.parentElement;

        while (scrollContainer && scrollContainer !== document.body) {
            const style = window.getComputedStyle(scrollContainer);
            const isScrollable = (style.overflowY === 'auto' || style.overflowY === 'scroll') &&
                                 scrollContainer.scrollHeight > scrollContainer.clientHeight;
            if (isScrollable) break;
            scrollContainer = scrollContainer.parentElement;
        }

        if (scrollContainer && scrollContainer !== document.body) {
            const containerRect = scrollContainer.getBoundingClientRect();
            const elementRect = element.getBoundingClientRect();
            const relativeTop = elementRect.top - containerRect.top;
            const targetPosition = scrollContainer.scrollTop + relativeTop - CONFIG.headerOffset;

            scrollContainer.scrollTo({
                top: Math.max(0, targetPosition),
                behavior: 'smooth'
            });
        } else {
            element.scrollIntoView({ behavior: 'instant', block: 'start' });
            setTimeout(() => {
                const scrollable = document.querySelector('[class*="react-scroll-to-bottom"]') ||
                                   document.querySelector('main');
                if (scrollable) {
                    scrollable.scrollBy({ top: -CONFIG.headerOffset, behavior: 'smooth' });
                }
            }, 50);
        }
    }

    function flashElement(el) {
        const originalTransition = el.style.transition;
        const originalBackground = el.style.backgroundColor;

        el.style.transition = 'background-color 0.4s ease';
        el.style.backgroundColor = 'rgba(255, 255, 255, 0.15)';

        setTimeout(() => {
            el.style.backgroundColor = originalBackground;
            setTimeout(() => {
                el.style.transition = originalTransition;
            }, 400);
        }, 600);
    }

    // =========================================================================
    // AUTO-REFRESH OBSERVER
    // =========================================================================
    function setupObserver() {
        let lastUrl = location.href;
        let debounceTimer = null;
        const knownMessageIds = new Set();

        const observer = new MutationObserver((mutations) => {
            // URL change detection
            if (location.href !== lastUrl) {
                lastUrl = location.href;
                state.expandedItems.clear();
                knownMessageIds.clear();
                clearTimeout(debounceTimer);
                loadConversation();
                return;
            }

            // New message detection
            if (!getUUID()) return;

            let foundNew = false;
            for (const mutation of mutations) {
                for (const node of mutation.addedNodes) {
                    if (node.nodeType !== Node.ELEMENT_NODE) continue;

                    const msgElements = [];
                    if (node.matches?.('[data-message-id]')) {
                        msgElements.push(node);
                    }
                    if (node.querySelectorAll) {
                        msgElements.push(...node.querySelectorAll('[data-message-id]'));
                    }

                    for (const el of msgElements) {
                        const id = el.getAttribute('data-message-id');
                        if (id && !knownMessageIds.has(id)) {
                            knownMessageIds.add(id);
                            foundNew = true;
                        }
                    }
                }
            }

            if (foundNew) {
                clearTimeout(debounceTimer);
                debounceTimer = setTimeout(loadConversation, CONFIG.debounceDelay);
            }
        });

        observer.observe(document.body, { subtree: true, childList: true });
    }

    // =========================================================================
    // INITIALIZATION
    // =========================================================================
    initUI();
    setTimeout(() => {
        loadConversation();
        setupObserver();
    }, CONFIG.debounceDelay);

})();