OpenRouter Chat Enhancements

Navigation hotkeys, message highlight, floating speaker, scroll protections, perfect collapse/expand handling, and enhanced edit scroll lock.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         OpenRouter Chat Enhancements
// @namespace    http://tampermonkey.net/
// @license      MIT
// @version      1.4.0
// @description  Navigation hotkeys, message highlight, floating speaker, scroll protections, perfect collapse/expand handling, and enhanced edit scroll lock.
// @author       cdr-x
// @match        https://openrouter.ai/chat*
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @run-at       document-end
// ==/UserScript==

(function () {
    'use strict';

    // Inject highlight CSS for selected message
    const highlightStyle = document.createElement('style');
    highlightStyle.textContent = `
        .openrouter-nav-highlight {
            outline: 2px solid #3b82f6 !important;
            background: rgba(59,130,246,0.08) !important;
            border-radius: 0.5rem !important;
            transition: outline 0.15s, background 0.15s;
        }
    `;
    document.head.appendChild(highlightStyle);

    /*********************** SETTINGS MODULE **********************/
    // MODULE_VERSION: [email protected]
    // Handles persistence and configuration
    class SettingsModule {
        constructor() {
            this.modifierKey = "Alt";
            this.panelEnabled = true;
            this.EDIT_LOCK_DURATION_MS = 3000;
            this.COLLAPSE_SCROLL_LOCK_MS = 500;
            this.ANTI_HYSTERESIS_MS = 50;
        }

        init() {
            this.modifierKey = GM_getValue('or_modifierKey', "Alt");
            this.panelEnabled = GM_getValue('or_panelEnabled', true);
        }

        save() {
            GM_setValue('or_modifierKey', this.modifierKey);
            GM_setValue('or_panelEnabled', this.panelEnabled);
        }
    }
    // Export to global namespace
    window.SettingsModule = SettingsModule;

    /*********************** UI MODULE **********************/
    // MODULE_VERSION: [email protected]
    // Manages visual components (navigation panel)
    class UIModule {
        constructor() {
            // Remove speakerElem, speakerImg, and speakerName properties since they're no longer used
            this.panelElem = null;
        }

        init() {
            if (this.settings && this.settings.panelEnabled) {
                this.createPanel();
            }
        }

        // Remove the commented out createSpeakerFloat method entirely since it's no longer needed
        
        createPanel() {
            this.clearPanel();
            if (!this.settings || !this.settings.panelEnabled) return;
            this.panelElem = document.createElement("div");
            this.panelElem.id = "openrouter-nav-panel";
            this.panelElem.innerHTML = `
                <button class="openrouter-nav-btn" title="Previous Message (k)">
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                        <polyline points="15 18 9 12 15 6"/>
                    </svg>
                </button>
                <button class="openrouter-nav-btn" title="Next Message (j)">
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                        <polyline points="9 18 15 12 9 6"/>
                    </svg>
                </button>
                <span class="openrouter-nav-divider"></span>
                <button class="openrouter-nav-btn" title="Top (Home)">
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                        <polyline points="18 15 12 9 6 15"/>
                    </svg>
                </button>
                <button class="openrouter-nav-btn" title="Bottom (End)">
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                        <polyline points="6 9 12 15 18 9"/>
                    </svg>
                </button>
                <span class="openrouter-nav-divider"></span>
                <button class="openrouter-nav-btn" title="Expand/Collapse (l/h)">
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                        <polyline points="7 13 12 18 17 13"/>
                        <polyline points="7 6 12 11 17 6"/>
                    </svg>
                </button>
            `;
            document.body.appendChild(this.panelElem);
        }

        clearPanel() {
            if (this.panelElem) { this.panelElem.remove(); this.panelElem = null; }
        }

        // Modify updateSpeaker to avoid duplicate speaker visualization
        updateSpeaker(msgDiv) {
            // This method is kept for backward compatibility but doesn't create UI elements anymore
            // The actual speaker visualization is now handled by NavigationModule.showSpeakerForMessage
            return; // Just return without doing anything
        }

        // Keep these methods as they're used by NavigationModule.showSpeakerForMessage
        getSpeakerName(msgDiv) {
            const hdr = this.msgHeader(msgDiv);
            if (!hdr) return "";
            const a = hdr.querySelector('span a');
            if (a) return a.textContent.replace(/\|.*/,'').replace('(edited)','').trim();
            const span = hdr.querySelector('span');
            if (span) return span.textContent.replace(/\|.*/,'').replace('(edited)','').trim();
            return "";
        }

        getSpeakerAvatar(msgDiv) {
            const hdr = this.msgHeader(msgDiv);
            if (!hdr) return "";
            const img = hdr.querySelector("picture img, img");
            if (img) return img.src;
            return "";
        }

        msgHeader(msgDiv) {
            return msgDiv.querySelector('.group.flex.flex-col.gap-2.items-start > .flex.gap-2, .group.flex.flex-col.gap-2.items-end > .flex.gap-2') ||
                   msgDiv.querySelector('.flex.gap-2.items-center, .flex.gap-2.flex-row-reverse');
        }
    }
    // Export to global namespace
    window.UIModule = UIModule;

    /*********************** NAVIGATION MODULE **********************/
    // MODULE_VERSION: [email protected]
    // Core message tracking and scrolling logic
    class NavigationModule {
        constructor() {
            this.scrollContainer = null;
            this.allMessages = [];
            this.highlighted = null;
            this.blockHighlightUntil = 0;
            this.lastInteractedMsg = null;
            this.latestInputEdit = 0;
            this.lastEditingMsg = null;
            this.editPasteProhibit = false;
            this.collapseRestoreMsg = null;
            this.speakerTooltip = null; // Add this line
            this.initSpeakerTooltip(); // Add this line
        }

        init(ui, settings) {
            this.ui = ui;
            this.settings = settings;
            this.scrollContainer = this.findScrollContainer();
            if (!this.scrollContainer) {
                console.warn("OpenRouter Chat Enhancements: Main chat container not found. Initialization aborted. The page might still be loading.");
                return;
            }
            // Removed this.setupObservers();
            this.ui.settings = settings; // pass settings to UI for panel visibility
            this.ui.init();
            this.updateMsgList();
            this.panelAndPageListeners();
            this.setupScrollListener();
            this.setupInputListeners();
            this.setupVisibilityAndResizeListeners();
        }

        findScrollContainer() {
            return document.querySelector('main div.overflow-y-scroll') ||
                   document.querySelector('main div[style*="overflow-y: auto;"]') ||
                   document.querySelector('main div[style*="overflow-y: scroll;"]') ||
                   document.querySelector('main');
        }

        findMessageContainers() {
            if (!this.scrollContainer) return [];
            // Relaxed: include all visible message containers, regardless of child structure
            return Array.from(
                this.scrollContainer.querySelectorAll('div.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0')
            ).filter(d => d.offsetParent !== null);
        }

        msgContentElem(msgDiv) {
            return msgDiv.querySelector('.overflow-auto') || msgDiv.querySelector('div.flex.max-w-full.flex-col.relative.overflow-auto');
        }

        msgToggleExpandBtn(msgDiv) {
            return msgDiv.querySelector(
                'div.group.flex.flex-col.gap-2.items-start > div.flex.max-w-full.flex-col.relative.overflow-auto.gap-1.items-start.w-full > div > div > button, ' +
                'div.group.flex.flex-col.gap-2.items-end > div.flex.max-w-full.flex-col.relative.overflow-auto.gap-1.items-end.w-full > div > div > button'
            );
        }

        updateMsgList() {
            let prevId = this.highlighted?.dataset?.ormsgid;
            this.allMessages = this.findMessageContainers();
            this.allMessages.forEach((m, i) => {
                if (!m.dataset.ormsgid) m.dataset.ormsgid = "msg-" + Date.now() + "-" + Math.random();
            });
            if (prevId) {
                this.highlighted = this.allMessages.find(m => m.dataset?.ormsgid === prevId);
            }
            if (!this.highlighted && this.allMessages.length > 0) {
                this.highlighted = this.allMessages[this.allMessages.length - 1];
            }
            this.allMessages.forEach(m => m.classList.toggle('openrouter-nav-highlight', m === this.highlighted));
            if (this.highlighted) {
                this.ui.updateSpeaker(this.highlighted);
            } else {
                this.ui.updateSpeaker(null);
            }
        }

        highlightMsg(msgDiv, opts = {}) {
            if (msgDiv === null) {
                if (this.highlighted) this.highlighted.classList.remove('openrouter-nav-highlight');
                this.highlighted = null;
                this.ui.updateSpeaker(null);
                this.showSpeakerForMessage(null); // Add this line to hide when no message is selected
                return;
            }
            if (!msgDiv || !document.body.contains(msgDiv)) return;
            if (this.editPasteProhibit && this.lastEditingMsg && this.lastEditingMsg !== msgDiv) return;
            if (Date.now() < this.blockHighlightUntil && !opts.force) return;
            if (this.highlighted) this.highlighted.classList.remove('openrouter-nav-highlight');
            this.highlighted = msgDiv;
            this.highlighted.classList.add('openrouter-nav-highlight');
            this.ui.updateSpeaker(this.highlighted);
            this.lastInteractedMsg = this.highlighted;
            this.showSpeakerForMessage(this.highlighted); // Add this line
            if (opts.scrollIntoView) {
                this.highlighted.scrollIntoView({ behavior: "smooth", block: opts.block || "center" });
                if (opts.scrollTop) {
                    let ct = this.msgContentElem(this.highlighted);
                    if (ct) ct.scrollTop = 0;
                }
                if (opts.scrollBottom) {
                    let ct = this.msgContentElem(this.highlighted);
                    if (ct) ct.scrollTop = ct.scrollHeight;
                }
            }
        }

        navToMsg(dir = 1) {
            if (!this.allMessages.length) return;
            let idx = this.highlighted ? this.allMessages.indexOf(this.highlighted) : -1;
            let nextIdx = idx + dir;
            if (nextIdx < 0) nextIdx = 0;
            if (nextIdx > this.allMessages.length - 1) nextIdx = this.allMessages.length - 1;
            this.blockHighlightUntil = Date.now() + 350;
            if (this.allMessages[nextIdx]) this.highlightMsg(this.allMessages[nextIdx], { scrollIntoView: true, force: true });
        }

        scrollMsgTop() {
            if (!this.highlighted) return;
            let ct = this.msgContentElem(this.highlighted);
            if (ct) ct.scrollTop = 0;
            this.highlighted.scrollIntoView({ behavior: "smooth", block: "start" });
            this.blockHighlightUntil = Date.now() + 300;
        }

        scrollMsgBottom() {
            if (!this.highlighted) return;
            let ct = this.msgContentElem(this.highlighted);
            if (ct) ct.scrollTop = ct.scrollHeight;
            this.highlighted.scrollIntoView({ behavior: "smooth", block: "end" });
            this.blockHighlightUntil = Date.now() + 300;
        }

        toggleMsgExpand() {
            if (!this.highlighted) return;
            const btn = this.msgToggleExpandBtn(this.highlighted);
            if (!btn) return;
            this.handleToggleScroll(this.highlighted);
            btn.click();
        }

        handleToggleScroll(msgDiv) {
            this.collapseRestoreMsg = msgDiv;
            const scrollContainer = this.findScrollContainer();
            const scrollTopBefore = scrollContainer.scrollTop;
            const msgTopBefore = msgDiv.offsetTop;
            const visualTop = msgTopBefore - scrollTopBefore;
            setTimeout(() => {
                let msg = this.allMessages.find(m => m.dataset.ormsgid === this.collapseRestoreMsg.dataset.ormsgid);
                if (msg) {
                    const msgTopAfter = msg.offsetTop;
                    scrollContainer.scrollTop = msgTopAfter - visualTop;
                    this.highlightMsg(msg, { force: true });
                    this.ensureScrollInBounds(msg);
                }
                this.collapseRestoreMsg = null;
                this.blockHighlightUntil = Date.now() + this.settings.COLLAPSE_SCROLL_LOCK_MS;
            }, 210);
        }

        refreshActiveMsg() {
            if (!this.highlighted) return;
            // Use the full SVG path selector from old script for refresh button
            const refreshSelectors = [
                'svg path[d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"]',
                'button[aria-label="Refresh"]',
                'button[title="Refresh"]',
                'button svg[viewBox="0 0 24 24"] path[d*="M17.65 6.35A10 10 0 1 1 6.35 17.65"]',
                'button svg[viewBox="0 0 24 24"] path[d*="M12 8v4l3 3m6-3a9 9 0 1 1-18 0 9 9 0 0 1-18 0z"]'
            ];

            let refreshButton = null;
            for (const selector of refreshSelectors) {
                const el = this.highlighted.querySelector(selector);
                if (el) {
                    refreshButton = el.closest('button') || el;
                    break;
                }
            }

            if (refreshButton) {
                refreshButton.click();
            }
        }

        updateHighlightOnScroll() {
            if (Date.now() < this.blockHighlightUntil) return;
            if (this.editPasteProhibit && this.lastEditingMsg) {
                this.ensureScrollInBounds(this.lastEditingMsg);
                return;
            }
            let best = null, maxVH = 0;
            const containerRect = this.scrollContainer.getBoundingClientRect();
            this.allMessages.forEach(m => {
                const rect = m.getBoundingClientRect();
                let top = Math.max(rect.top, containerRect.top);
                let bot = Math.min(rect.bottom, containerRect.bottom);
                let visH = Math.max(0, bot - top);
                if (visH > maxVH && visH > 48) {
                    maxVH = visH;
                    best = m;
                }
            });
            if (best && best !== this.highlighted) {
                this.highlightMsg(best);
            }
        }

        enforceScrollBoundOnEdit() {
            const act = document.activeElement;
            if (act && act.closest('.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0') && (act.matches('input:not([type="checkbox"]):not([type="radio"]), textarea, [contenteditable="true"]'))) {
                const activeMsg = act.closest('.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0');
                if (activeMsg && document.body.contains(activeMsg)) {
                    this.lastEditingMsg = activeMsg;
                    this.latestInputEdit = Date.now();
                    this.editPasteProhibit = true;
                    this.highlightMsg(activeMsg, { force: true });
                    this.ensureScrollInBounds(activeMsg);
                    if (this.scrollLockTimeout) clearTimeout(this.scrollLockTimeout);
                    this.scrollLockTimeout = setTimeout(() => {
                        if (Date.now() - this.latestInputEdit >= this.settings.EDIT_LOCK_DURATION_MS) {
                            this.editPasteProhibit = false;
                            this.lastEditingMsg = null;
                            this.scrollLockTimeout = null;
                        }
                    }, this.settings.EDIT_LOCK_DURATION_MS);
                }
            }
        }

        ensureScrollInBounds(msgDiv) {
            if (!msgDiv || !this.scrollContainer) return;
            const msgRect = msgDiv.getBoundingClientRect();
            const scRect = this.scrollContainer.getBoundingClientRect();
            if (msgRect.top < scRect.top || msgRect.bottom > scRect.bottom) {
                msgDiv.scrollIntoView({ behavior: "auto", block: "center" });
            }
        }

        disableContainerScroll() {
            if (this.scrollContainer) this.scrollContainer.style.overflowY = 'hidden';
        }

        enableContainerScroll() {
            if (this.scrollContainer) this.scrollContainer.style.overflowY = 'auto';
        }

        panelAndPageListeners() {
            this.scrollContainer.addEventListener('click', e => {
                const msg = e.target.closest('.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0');
                if (msg && this.allMessages.includes(msg)) this.highlightMsg(msg, { force: true });
            });
            this.scrollContainer.addEventListener('focusin', e => {
                const msg = e.target.closest('.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0, [data-ormsgid]');
                if (msg && this.allMessages.includes(msg)) this.highlightMsg(msg, { force: true });
            });
            this.scrollContainer.addEventListener('mousedown', e => {
                const msg = e.target.closest('.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0, [data-ormsgid]');
                if (msg && this.allMessages.includes(msg)) this.highlightMsg(msg, { force: true });
            });
            const observer = new MutationObserver(() => {
                requestAnimationFrame(() => {
                    requestAnimationFrame(() => this.updateMsgList());
                });
            });
            observer.observe(this.scrollContainer, { childList: true, subtree: true });

            const expandCollapseSelector = 'div.group.flex.flex-col.gap-2.items-start > div.flex.max-w-full.flex-col.relative.overflow-auto.gap-1.items-start.w-full > div > div > button, ' +
                                           'div.group.flex.flex-col.gap-2.items-end > div.flex.max-w.full.flex-col.relative.overflow-auto.gap-1.items-end.w-full > div > div > button';
            this.scrollContainer.addEventListener('mousedown', e => {
                const btn = e.target.closest(expandCollapseSelector);
                if (btn) {
                    const msgDiv = btn.closest('.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0');
                    if (msgDiv && this.allMessages.includes(msgDiv)) {
                        this.handleToggleScroll(msgDiv);
                    }
                }
            });
        }

        setupScrollListener() {
            let lastScrollUpd = 0;
            this.scrollContainer.addEventListener('scroll', () => {
                if (Date.now() - lastScrollUpd > this.settings.ANTI_HYSTERESIS_MS) {
                    this.updateHighlightOnScroll();
                    lastScrollUpd = Date.now();
                }
                if (this.editPasteProhibit && this.lastEditingMsg) {
                    this.ensureScrollInBounds(this.lastEditingMsg);
                }
            }, { passive: true });

            // Add wheel event listener to allow mouse wheel scrolling without interference
            this.scrollContainer.addEventListener('wheel', (e) => {
                // Do not blur or interfere with scrolling on wheel
                // Just allow the event to propagate normally
                // This prevents any focus blur that might block scrolling
                e.stopPropagation();
            }, { passive: true });
        }

        setupInputListeners() {
            document.addEventListener('input', () => this.enforceScrollBoundOnEdit(), true);
            document.addEventListener('paste', (e) => {
                this.enforceScrollBoundOnEdit();
                this.disableContainerScroll();
                setTimeout(() => this.enableContainerScroll(), 100);
            }, true);
            document.addEventListener('cut', () => this.enforceScrollBoundOnEdit(), true);

            document.addEventListener('focusout', () => {
                if (this.editPasteProhibit && Date.now() - this.latestInputEdit > this.settings.EDIT_LOCK_DURATION_MS / 2) {
                    this.editPasteProhibit = false;
                    this.lastEditingMsg = null;
                }
            }, true);

            setInterval(() => this.updateMsgList(), 880);
        }

        setupVisibilityAndResizeListeners() {
            document.addEventListener('visibilitychange', () => {
                if (document.visibilityState === "visible") setTimeout(() => this.updateMsgList(), 500);
            });

            window.addEventListener('resize', () => { setTimeout(() => this.updateHighlightOnScroll(), 80); });
        }

        initSpeakerTooltip() { // Add this new method
            this.speakerTooltip = document.createElement('div');
            this.speakerTooltip.id = 'speaker-tooltip-ch';
            Object.assign(this.speakerTooltip.style, {
                position: 'fixed',
                top: '10px',
                left: '50%',
                transform: 'translateX(-50%)',
                backgroundColor: 'rgba(40, 40, 40, 0.9)',
                color: 'white',
                padding: '5px 10px',
                borderRadius: '8px',
                zIndex: '10001',
                fontSize: '14px',
                fontWeight: 'bold',
                display: 'flex', // Use flex for image and text
                alignItems: 'center', // Align items vertically
                gap: '8px', // Space between image and text
                opacity: '0', // Initially hidden, controlled by showSpeakerForMessage
                visibility: 'hidden', // Initially hidden
                boxShadow: '0 2px 10px rgba(0,0,0,0.5)',
                transition: 'opacity 0.2s ease-in-out, transform 0.2s ease-in-out, visibility 0.2s ease-in-out'
            });
            document.body.appendChild(this.speakerTooltip);
        }

        showSpeakerForMessage(messageElement) { // Add this new method
            if (!this.speakerTooltip) this.initSpeakerTooltip();

            if (!messageElement) {
                this.speakerTooltip.style.opacity = '0';
                this.speakerTooltip.style.transform = 'translateX(-50%) translateY(-20px)';
                this.speakerTooltip.style.visibility = 'hidden';
                return;
            }

            // Use the same speaker name retrieval logic as in the UI module for consistency
            let speakerName = "Unknown Speaker";
            if (this.ui && this.ui.getSpeakerName) {
                speakerName = this.ui.getSpeakerName(messageElement) || speakerName;
            } else {
                // Fallback to previous logic if UI module is not available
                const speakerElement = messageElement.querySelector(
                    '.font-semibold, div[class*="speaker" i], span[class*="name" i], [data-testid*="speaker" i], [aria-label*="speaker" i]'
                );
                if (speakerElement) {
                    speakerName = speakerElement.textContent.trim();
                }
            }

            // Try to find speaker avatar
            let speakerAvatarSrc = null;
            const imgAvatar = messageElement.querySelector('img[alt]:not([alt=""]):not([alt*="logo"])');
            if (imgAvatar) {
                speakerAvatarSrc = imgAvatar.src;
            } else {
                const divAvatars = messageElement.querySelectorAll('div[style*="background-image"]');
                for (let divAvatar of divAvatars) {
                    const style = divAvatar.style.backgroundImage;
                    if (style && style.includes('url(')) {
                        if (divAvatar.offsetWidth > 10 && divAvatar.offsetWidth < 100 && divAvatar.offsetHeight > 10 && divAvatar.offsetHeight < 100) {
                            speakerAvatarSrc = style.substring(style.indexOf('url("') + 4, style.lastIndexOf(')')).replace(/["|']/g, "");
                            break;
                        }
                    }
                }
            }

            speakerName = speakerName.replace(/avatar/i, "").trim();
            if (!speakerName || speakerName.toLowerCase() === 'user' || speakerName.toLowerCase() === 'assistant') {
                const firstStrongBold = messageElement.querySelector('strong, b');
                if (firstStrongBold && firstStrongBold.parentElement.children.length === 1) {
                    speakerName = firstStrongBold.textContent.trim();
                }
            }

            this.speakerTooltip.innerHTML = '';

            if (speakerAvatarSrc) {
                const avatarImg = document.createElement('img');
                avatarImg.src = speakerAvatarSrc;
                Object.assign(avatarImg.style, {
                    width: '24px',
                    height: '24px',
                    borderRadius: '50%',
                    objectFit: 'cover'
                });
                this.speakerTooltip.appendChild(avatarImg);
            }

            const nameSpan = document.createElement('span');
            nameSpan.textContent = speakerName;
            this.speakerTooltip.appendChild(nameSpan);

            this.speakerTooltip.style.visibility = 'visible';
            this.speakerTooltip.style.opacity = '1';
            this.speakerTooltip.style.transform = 'translateX(-50%) translateY(0)';
        }
    }
    // Export to global namespace
    window.NavigationModule = NavigationModule;

    /*********************** HOTKEY MODULE **********************/
    // MODULE_VERSION: [email protected]
    // Centralizes keyboard event handling
    class HotkeyModule {
        constructor(settings, navigation, ui) {
            this.settings = settings;
            this.navigation = navigation;
            this.ui = ui;
            this.lastFocusedMsg = null;

            this.patchedRetry = null;
            this.patchRetryFunction();
        }

        patchRetryFunction() {
            // Wait for the React context or message dispatch to be available
            const tryPatch = () => {
                try {
                    // Access the message dispatch from the navigation or ui
                    // Heuristic: navigation has a messages object with a dispatch method
                    if (this.navigation && this.navigation.ui && this.navigation.ui.messageDispatch) {
                        const dispatch = this.navigation.ui.messageDispatch;
                        if (dispatch && dispatch.retry && !dispatch.retry.__patched) {
                            const originalRetry = dispatch.retry;
                            const self = this;
                            dispatch.retry = async function(message, options) {
                                // Ignore isProcessing flag by not checking it
                                // Just call the original retry
                                return originalRetry.call(this, message, options);
                            };
                            dispatch.retry.__patched = true;
                            this.patchedRetry = dispatch.retry;
                            return true;
                        }
                    }
                    // Fallback: try to find global retry function
                    if (window && window.__RETRY_FUNCTION__ && !window.__RETRY_FUNCTION__.__patched) {
                        const originalRetry = window.__RETRY_FUNCTION__;
                        window.__RETRY_FUNCTION__ = async function(message, options) {
                            return originalRetry(message, options);
                        };
                        window.__RETRY_FUNCTION__.__patched = true;
                        this.patchedRetry = window.__RETRY_FUNCTION__;
                        return true;
                    }
                } catch (e) {
                    // Ignore errors
                }
                return false;
            };

            const intervalId = setInterval(() => {
                if (tryPatch()) {
                    clearInterval(intervalId);
                }
            }, 200);
        }

        async concurrentRetry(message) {
            if (this.patchedRetry) {
                await this.patchedRetry(message);
            } else {
                // Fallback: click refresh button
                this.navigation.refreshActiveMsg();
            }
        }

        init() {
            document.addEventListener('keydown', this.handleKey.bind(this));
        }

        isModifier(event) {
            if (this.settings.modifierKey === "None") return !event.ctrlKey && !event.altKey;
            if (this.settings.modifierKey === "Ctrl") return event.ctrlKey && !event.altKey;
            if (this.settings.modifierKey === "Alt") return event.altKey && !event.ctrlKey;
            return false;
        }

        isEditing() {
            const act = document.activeElement;
            return act && (act.matches('input, textarea, [contenteditable]'));
        }

        focusMainInput() {
            // Find all visible, enabled, non-readonly textareas/inputs
            const candidates = Array.from(document.querySelectorAll('textarea, input[type="text"], input:not([type])'))
                .filter(el => el.offsetParent !== null && !el.disabled && !el.readOnly);
            if (!candidates.length) return;
            // Pick the one closest to the bottom of the viewport (main chat input is usually at the bottom)
            let best = candidates[0];
            let maxBottom = -Infinity;
            candidates.forEach(el => {
                const rect = el.getBoundingClientRect();
                if (rect.bottom > maxBottom) {
                    maxBottom = rect.bottom;
                    best = el;
                }
            });
            best.focus();
            if (best.value && best.selectionStart !== undefined) best.selectionStart = best.value.length;
        }

        async handleKey(e) {
            let cancelledEdit = false; // Flag to track if Escape cancelled an edit

            // --- ESCAPE HANDLING ---
            if (e.key === "Escape") {
                const act = document.activeElement;
                const activeMsgContainer = act?.closest('.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0');

                // If editing a message (textarea inside a message)
                if (activeMsgContainer && act.matches('textarea, [contenteditable]')) {
                    // Enhanced Cancel Button Finder with multiple strategies
                    let cancelBtn = null;
                    
                    // Strategy 1: Look for buttons with "Cancel" text or aria-label
                    const buttonsInMsg = Array.from(activeMsgContainer.querySelectorAll('button, [role="button"][type="button"], [type="button"]'));
                    cancelBtn = buttonsInMsg.find(btn => 
                        /cancel/i.test(btn.textContent || btn.innerText || btn.getAttribute('aria-label') || '')
                    );
                    
                    // Strategy 2: Look for buttons that appear during edit mode (often positioned near the textarea)
                    if (!cancelBtn) {
                        const editControls = act.closest('div')?.nextElementSibling;
                        if (editControls && editControls.querySelectorAll('button').length) {
                            const controlButtons = Array.from(editControls.querySelectorAll('button'));
                            // First button is often "Cancel" in edit controls
                            cancelBtn = controlButtons[0];
                        }
                    }
                    
                    // Strategy 3: Look for buttons with specific classes that might indicate cancel functionality
                    if (!cancelBtn) {
                        cancelBtn = activeMsgContainer.querySelector('button[class*="cancel" i], button[class*="secondary" i]');
                    }

                    if (cancelBtn) {
                        const msgToKeepSelected = activeMsgContainer;
                        
                        // Determine current scroll position relative to the message
                        const msgRect = activeMsgContainer.getBoundingClientRect();
                        const viewportHeight = window.innerHeight;
                        const msgCenter = msgRect.top + (msgRect.height / 2);
                        const isAboveHalfway = msgCenter < (viewportHeight / 2);
                        
                        cancelBtn.click();
                        
                        // Re-highlight the same message after cancelling edit with smart scrolling
                        setTimeout(() => {
                            if (msgToKeepSelected && document.body.contains(msgToKeepSelected)) {
                                this.navigation.highlightMsg(msgToKeepSelected, { 
                                    scrollIntoView: true, 
                                    force: true,
                                    // If above halfway point, scroll to top; otherwise scroll to bottom
                                    scrollTop: isAboveHalfway,
                                    scrollBottom: !isAboveHalfway
                                });
                            }
                            if (this.navigation.scrollContainer) {
                                this.navigation.scrollContainer.focus({ preventScroll: true });
                            }
                        }, 50); // Short delay
                        
                        cancelledEdit = true; // Set the flag
                        e.preventDefault();
                        return; // Stop further Escape processing for this event
                    }
                }
                // If main chat input is focused (robust: bottom-most visible textarea/input in a form)
                if (act && (act.matches('textarea, input[type="text"], input:not([type])'))) {
                    // Always blur the main chat input on Escape
                    act.blur();
                    // Restore highlight to last selected message
                    if (this.lastFocusedMsg && document.body.contains(this.lastFocusedMsg)) {
                        this.navigation.highlightMsg(this.lastFocusedMsg, { scrollIntoView: true, force: true });
                    }
                    e.preventDefault();
                    return;
                }
                // If a message is selected AND we didn't just cancel an edit, deselect
                if (!cancelledEdit && this.navigation.highlighted) {
                    this.lastFocusedMsg = this.navigation.highlighted;
                    this.navigation.highlightMsg(null);
                    // Optionally, focus the scroll container
                    if (this.navigation.scrollContainer) this.navigation.scrollContainer.focus();
                    e.preventDefault();
                    return;
                }
            }

            // --- CTRL+I: Focus main chat input ---
            if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key.toLowerCase() === "i") {
                this.focusMainInput();
                e.preventDefault();
                return;
            }

            // --- Only process below if not editing or if allowed ---
            if (
                e.target.matches('input, textarea, [contenteditable]') &&
                !["Home", "End", "PageUp", "PageDown"].includes(e.key)
            ) return;
            if (!this.isModifier(e)) return;

            let handled = false;
            switch (e.key) {
                // --- INVERTED NAVIGATION: j = up, k = down ---
                case 'j':
                    this.navigation.navToMsg(-1); // up
                    handled = true;
                    break;
                case 'k':
                    this.navigation.navToMsg(1); // down
                    handled = true;
                    break;
                // --- Expand/Collapse ---
                case 'l':
                case 'h':
                    this.navigation.toggleMsgExpand();
                    handled = true;
                    break;
                // --- Home/End: scroll within selected message (not in edit mode) ---
                case 'Home':
                    if (!this.isEditing() && this.navigation.highlighted) {
                        this.navigation.scrollMsgTop();
                        handled = true;
                    }
                    break;
                case 'End':
                    if (!this.isEditing() && this.navigation.highlighted) {
                        this.navigation.scrollMsgBottom();
                        handled = true;
                    }
                    break;
                // --- Refresh selected message ---
                case 'r':
                    if (this.navigation.highlighted) {
                        // Find the retry button in the highlighted message
                        const retryBtn = this.navigation.highlighted.querySelector('button[aria-label="Retry"], button[title="Retry"], button[aria-label*="Retry"], button[title*="Retry"]');
                        if (retryBtn) {
                            // If disabled, temporarily enable it to allow click
                            const wasDisabled = retryBtn.disabled;
                            if (wasDisabled) {
                                retryBtn.disabled = false;
                            }
                            retryBtn.click();
                            if (wasDisabled) {
                                // Restore disabled state after click
                                setTimeout(() => {
                                    retryBtn.disabled = true;
                                }, 100);
                            }
                            handled = true;
                        } else {
                            // Fallback: refresh active message
                            this.navigation.refreshActiveMsg();
                            handled = true;
                        }
                    }
                    break;
                // --- Copy button for selected message ---
                case 'c':
                    if (this.navigation.highlighted) {
                        let copyBtn = this.navigation.highlighted.querySelector('button[aria-label*="Copy"], button[title*="Copy"], button svg[aria-label*="Copy"], button svg[title*="Copy"]');
                        if (!copyBtn) {
                            // Try fallback: first button with copy icon
                            copyBtn = Array.from(this.navigation.highlighted.querySelectorAll('button')).find(btn =>
                                btn.innerHTML.match(/copy/i)
                            );
                        }
                        if (copyBtn) {
                            copyBtn.click();
                            handled = true;
                        }
                    }
                    break;
                // --- Edit button for selected message ---
                case 'e':
                    if (this.navigation.highlighted) {
                        // If already editing, just focus the existing textarea
                        if (this.navigation.highlighted.querySelector('textarea, [contenteditable]')) {
                            const existingTextarea = this.navigation.highlighted.querySelector('textarea, [contenteditable]');
                            if (existingTextarea) {
                                existingTextarea.focus();
                                // Move cursor to end
                                if (existingTextarea.setSelectionRange) {
                                    const len = existingTextarea.value.length;
                                    existingTextarea.setSelectionRange(len, len);
                                }
                            }
                            handled = true;
                            break;
                        }

                        let editBtn = null;
                        const buttons = Array.from(this.navigation.highlighted.querySelectorAll('button'));

                        // Priority 1: Button with specific SVG path (most reliable if path is stable)
                        editBtn = buttons.find(btn => btn.querySelector('svg path[d^="m16.862 4.487"]'));

                        // Priority 2: Button with text content "Edit" (from old script, good for accessibility)
                        if (!editBtn) {
                            editBtn = buttons.find(btn => (btn.textContent || btn.innerText || "").trim().toLowerCase() === 'edit');
                        }

                        // Priority 3: Button with aria-label or title containing "Edit"
                        if (!editBtn) {
                            editBtn = buttons.find(btn => {
                                const ariaLabel = btn.getAttribute('aria-label') || "";
                                const title = btn.getAttribute('title') || "";
                                return /edit/i.test(ariaLabel) || /edit/i.test(title);
                            });
                        }

                        // Priority 4: Button containing an SVG with a title or aria-label "Edit"
                        if (!editBtn) {
                            editBtn = buttons.find(btn => {
                                const svg = btn.querySelector('svg');
                                if (!svg) return false;
                                const svgTitle = svg.querySelector('title')?.textContent;
                                const svgAriaLabel = svg.getAttribute('aria-label');
                                return /edit/i.test(svgTitle || "") || /edit/i.test(svgAriaLabel || "");
                            });
                        }

                        if (editBtn) {
                            const msgContainer = this.navigation.highlighted;
                            
                            // Store a reference to the message before clicking
                            const msgId = msgContainer.dataset.ormsgid;
                            
                            // Set up a MutationObserver to detect when the textarea appears
                            let editObserver = null;
                            const setupObserver = () => {
                                if (editObserver) return; // Only set up once
                                
                                const currentMsg = document.querySelector(`[data-ormsgid="${msgId}"]`);
                                if (!currentMsg) return;
                                
                                editObserver = new MutationObserver((mutations, observer) => {
                                    for (const mutation of mutations) {
                                        if (mutation.type === 'childList' || mutation.type === 'subtree') {
                                            const editableSelectors = [
                                                'textarea', 
                                                '[contenteditable="true"]', 
                                                '[contenteditable]',
                                                'div[role="textbox"]',
                                                '.ProseMirror',
                                                '[data-slate-editor]'
                                            ];
                                            
                                            for (const selector of editableSelectors) {
                                                const editArea = currentMsg.querySelector(selector);
                                                if (editArea) {
                                                    // Focus immediately when detected
                                                    editArea.focus();
                                                    
                                                    // Move cursor to the end
                                                    if (editArea.setSelectionRange) {
                                                        const len = editArea.value.length;
                                                        editArea.setSelectionRange(len, len);
                                                    } else if (editArea.isContentEditable) {
                                                        try {
                                                            const range = document.createRange();
                                                            const sel = window.getSelection();
                                                            range.selectNodeContents(editArea);
                                                            range.collapse(false); // to the end
                                                            sel.removeAllRanges();
                                                            sel.addRange(range);
                                                        } catch (e) {
                                                            // Fallback if range manipulation fails
                                                            editArea.focus();
                                                        }
                                                    }
                                                    
                                                    // Disconnect after successful focus
                                                    observer.disconnect();
                                                    editObserver = null;
                                                    return;
                                                }
                                            }
                                        }
                                    }
                                });
                                
                                // Observe the message container for changes
                                editObserver.observe(currentMsg, { 
                                    childList: true, 
                                    subtree: true,
                                    attributes: true,
                                    characterData: true
                                });
                                
                                // Set a timeout to disconnect the observer if it doesn't find anything
                                setTimeout(() => {
                                    if (editObserver) {
                                        editObserver.disconnect();
                                        editObserver = null;
                                    }
                                }, 3000); // 3 second timeout
                            };
                            
                            // Click the edit button
                            editBtn.click();
                            
                            // Set up the observer immediately
                            setupObserver();
                            
                            // Also use our previous approach with multiple attempts as a fallback
                            const focusEditArea = (attempt = 1) => {
                                const currentMsg = document.querySelector(`[data-ormsgid="${msgId}"]`);
                                if (!currentMsg) return;
                                
                                const editableSelectors = [
                                    'textarea', 
                                    '[contenteditable="true"]', 
                                    '[contenteditable]',
                                    'div[role="textbox"]',
                                    '.ProseMirror',
                                    '[data-slate-editor]'
                                ];
                                
                                let editArea = null;
                                for (const selector of editableSelectors) {
                                    editArea = currentMsg.querySelector(selector);
                                    if (editArea) break;
                                }
                                
                                if (editArea) {
                                    // Focus with a slight delay to ensure the element is ready
                                    setTimeout(() => {
                                        editArea.focus();
                                        
                                        // Move cursor to the end
                                        if (editArea.setSelectionRange) {
                                            const len = editArea.value.length;
                                            editArea.setSelectionRange(len, len);
                                        } else if (editArea.isContentEditable) {
                                            try {
                                                const range = document.createRange();
                                                const sel = window.getSelection();
                                                range.selectNodeContents(editArea);
                                                range.collapse(false);
                                                sel.removeAllRanges();
                                                sel.addRange(range);
                                            } catch (e) {
                                                editArea.focus();
                                            }
                                        }
                                    }, 10);
                                } else if (attempt < 5) { // Try up to 5 times
                                    // Use exponential backoff for retry timing
                                    setTimeout(() => focusEditArea(attempt + 1), Math.pow(2, attempt) * 50);
                                }
                            };
                            
                            // Try to focus immediately
                            focusEditArea(1);
                            
                            // And also after a short delay
                            setTimeout(() => focusEditArea(2), 100);
                            
                            // And after a longer delay as a last resort
                            setTimeout(() => focusEditArea(3), 300);
                            
                            handled = true;
                        }
                    }
                    break;
// ...existing code...
            }
            if (handled) e.preventDefault();
        }
    }
    // Export to global namespace
    window.HotkeyModule = HotkeyModule;

    /******************** INIT ENTRYPOINT ********************/
    async function initPowerNav() {
        // Initialize core modules
        const settings = new SettingsModule();
        const ui = new UIModule();
        const navigation = new NavigationModule();
        const hotkeys = new HotkeyModule(settings, navigation, ui);
        
        // Setup modules
        settings.init();

        // Register menu commands for settings
        GM_registerMenuCommand("Set Hotkey Modifier: (Alt/Ctrl/None)", () => {
            const val = prompt('Use which key as the hotkey modifier? (Alt, Ctrl, None)', settings.modifierKey);
            if (!val) return;
            const normalized = val.trim().toLowerCase();
            const ok = { alt: "Alt", ctrl: "Ctrl", none: "None" }[normalized];
            if (ok) {
                settings.modifierKey = ok;
                settings.save();
                alert("Modifier set to: " + ok);
            } else {
                alert("Invalid. Must be Alt, Ctrl or None.");
            }
        });

        GM_registerMenuCommand("Toggle Navigation Panel", () => {
            settings.panelEnabled = !settings.panelEnabled;
            settings.save();
            if (settings.panelEnabled) {
                ui.createPanel();
            } else {
                ui.clearPanel();
            }
            alert("Navigation panel " + (settings.panelEnabled ? "enabled" : "disabled") + ". Refresh page if needed.");
        });

        navigation.init(ui, settings);
        hotkeys.init();
    }
    // Export init to global namespace
    window.initPowerNav = initPowerNav;

    // Delay initialization slightly to allow dynamic content loading after document-end
    setTimeout(initPowerNav, 500);

})();