ChatGPT Question Sidebar Navigation

A lightweight, feature-rich question sidebar for ChatGPT with resizing, dark mode, hover-previews, pinning, and state persistence.

Versione datata 08/10/2025. Vedi la nuova versione l'ultima versione.

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name        ChatGPT Question Sidebar Navigation
// @namespace   vanilla-js-enhanced-fixed
// @version     2.3.0
// @description A lightweight, feature-rich question sidebar for ChatGPT with resizing, dark mode, hover-previews, pinning, and state persistence.
// @match       https://chatgpt.com/*
// @grant       GM_addStyle
// @license     MIT
// ==/UserScript==

(function () {
    'use strict';

    // --- 1. STYLING ---
    const styles = `
        :root {
            --q-nav-bg: #fff;
            --q-nav-text: #333;
            --q-nav-text-secondary: #555;
            --q-nav-border: #e5e5e5;
            --q-nav-hover-bg: #f0f0f0;
            --q-nav-active-bg: #e7f3ff;
            --q-nav-active-text: #1a73e8;
            --q-nav-pin-color: #f6ad55;
            --q-nav-scrollbar-thumb: #ccc;
            --q-nav-scrollbar-track: #f1f1f1;
        }

        html.dark #q-nav-container, html.dark #q-nav-tooltip {
            --q-nav-bg: #2a2a2a;
            --q-nav-text: #f0f0f0;
            --q-nav-text-secondary: #bbb;
            --q-nav-border: #444;
            --q-nav-hover-bg: #3a3a3a;
            --q-nav-active-bg: #1a3c5f;
            --q-nav-active-text: #6ea7f1;
            --q-nav-pin-color: #f6ad55;
            --q-nav-scrollbar-thumb: #555;
            --q-nav-scrollbar-track: #333;
        }

        #q-nav-container {
            position: fixed;
            top: 10vh;
            right: 16px;
            height: auto;
            max-height: 70vh;
            background: var(--q-nav-bg);
            border: 1px solid var(--q-nav-border);
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.1);
            padding: 12px;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
            font-size: 14px;
            z-index: 9999;
            transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
            color: var(--q-nav-text);
            display: flex;
            flex-direction: column;
            min-width: 180px;
            max-width: 50vw;
        }

        #q-nav-container.q-nav-hidden {
            transform: translateX(calc(100% - 20px));
            opacity: 0.4;
        }

        #q-nav-container.q-nav-hidden:hover {
            transform: translateX(0);
            opacity: 1;
            box-shadow: 0 6px 20px rgba(0,0,0,0.2);
        }

        #q-nav-resizer {
            position: absolute;
            left: -5px;
            top: 0;
            width: 10px;
            height: 100%;
            cursor: col-resize;
            z-index: 10000;
        }

        #q-nav-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding-bottom: 10px;
            border-bottom: 1px solid var(--q-nav-border);
            font-weight: 600;
            user-select: none;
        }

        #q-nav-toggle {
            cursor: pointer;
            padding: 2px;
        }

        #q-nav-list-wrapper {
            overflow-y: auto;
            margin-top: 10px;
            scrollbar-width: thin;
            scrollbar-color: var(--q-nav-scrollbar-thumb) var(--q-nav-scrollbar-track);
        }
        #q-nav-list-wrapper::-webkit-scrollbar { width: 6px; }
        #q-nav-list-wrapper::-webkit-scrollbar-track { background: var(--q-nav-scrollbar-track); border-radius: 3px; }
        #q-nav-list-wrapper::-webkit-scrollbar-thumb { background: var(--q-nav-scrollbar-thumb); border-radius: 3px; }
        #q-nav-list-wrapper::-webkit-scrollbar-thumb:hover { background: #888; }

        .q-nav-section-header {
            font-size: 12px;
            font-weight: bold;
            color: var(--q-nav-text-secondary);
            margin: 10px 0 5px;
            padding: 0 5px;
            text-transform: uppercase;
        }
        .q-nav-section-header:first-child { margin-top: 0; }
        .q-nav-section-divider {
            border: 0;
            border-top: 1px solid var(--q-nav-border);
            margin: 10px 0;
        }

        #q-nav-pinned-list, #q-nav-questions-list {
            list-style: none;
            padding: 0;
            margin: 0;
        }

        #q-nav-list-wrapper li {
            position: relative;
            display: flex;
            align-items: center;
            padding: 8px 24px 8px 5px;
            cursor: pointer;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            border-radius: 4px;
            color: var(--q-nav-text-secondary);
        }

        #q-nav-list-wrapper li:hover {
            background-color: var(--q-nav-hover-bg);
        }

        #q-nav-list-wrapper li.q-nav-active {
            background-color: var(--q-nav-active-bg);
            color: var(--q-nav-active-text);
            font-weight: 500;
        }

        .q-nav-pin-icon {
            position: absolute;
            right: 5px;
            top: 50%;
            transform: translateY(-50%);
            opacity: 0;
            transition: opacity 0.15s;
            fill: var(--q-nav-text-secondary);
        }

        #q-nav-list-wrapper li:hover .q-nav-pin-icon {
            opacity: 0.6;
        }
        .q-nav-pin-icon:hover {
            opacity: 1 !important;
            fill: var(--q-nav-pin-color) !important;
        }
        .q-nav-pinned .q-nav-pin-icon {
            opacity: 1;
            fill: var(--q-nav-pin-color);
        }

        #q-nav-tooltip {
            position: fixed;
            opacity: 0;
            transition: opacity 0.2s ease-in-out;
            background: var(--q-nav-bg);
            border: 1px solid var(--q-nav-border);
            border-radius: 6px;
            padding: 8px 12px;
            box-shadow: 0 2px 8px rgba(0,0,0,0.15);
            max-width: 400px;
            font-size: 13px;
            z-index: 10001;
            pointer-events: none;
            white-space: pre-wrap;
            line-height: 1.5;
            color: var(--q-nav-text);
        }
    `;

    const ICONS = {
        open: '👁️',
        closed: '👁️‍🗨️',
        pin: `<svg class="q-nav-pin-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>`
    };

    let sidebarElement = null;
    let tooltipElement = null;
    let questionElements = [];
    let activeIndex = -1;
    let scrollContainer = null;
    let isResizing = false;
    let pageObserver = null;
    let themeObserver = null;
    let currentPath = location.pathname;

    let settings = {
        isOpen: JSON.parse(localStorage.getItem('qNavSettings_isOpen')) ?? true,
        width: localStorage.getItem('qNavSettings_width') || '250px'
    };

    function saveSettings() {
        localStorage.setItem('qNavSettings_isOpen', JSON.stringify(settings.isOpen));
        localStorage.setItem('qNavSettings_width', settings.width);
    }

    function getConversationId() {
        try {
            return location.pathname.split('/c/')[1].split('/')[0];
        } catch (e) {
            return null;
        }
    }

    function loadPinnedItems(convoId) {
        if (!convoId) return [];
        return JSON.parse(localStorage.getItem(`qNavPinned_${convoId}`)) || [];
    }

    function savePinnedItems(convoId, items) {
        if (!convoId) return;
        localStorage.setItem(`qNavPinned_${convoId}`, JSON.stringify(items));
    }

    function throttle(func, limit) {
        let inThrottle;
        return function(...args) {
            if (!inThrottle) {
                func.apply(this, args);
                inThrottle = true;
                setTimeout(() => inThrottle = false, limit);
            }
        };
    }

    function getChatContainer() {
        return document.querySelector("main .flex.flex-col.text-sm");
    }

    function queryQuestionElements() {
        const container = getChatContainer();
        if (!container) return [];
        return Array.from(container.querySelectorAll('div[data-message-author-role="user"]'));
    }

    function createSidebar() {
        if (document.getElementById('q-nav-container')) return;

        GM_addStyle(styles);

        sidebarElement = document.createElement('div');
        sidebarElement.id = 'q-nav-container';
        document.body.appendChild(sidebarElement);

        sidebarElement.innerHTML = `
            <div id="q-nav-resizer"></div>
            <div id="q-nav-header">
                <span>📄 Questions</span>
                <span id="q-nav-toggle" title="Toggle Sidebar"></span>
            </div>
            <div id="q-nav-list-wrapper">
                <div id="q-nav-pinned-section" style="display: none;">
                    <div class="q-nav-section-header">Pinned</div>
                    <ul id="q-nav-pinned-list"></ul>
                    <hr class="q-nav-section-divider" />
                </div>
                <div id="q-nav-questions-section" style="display: none;">
                     <div class="q-nav-section-header">Questions</div>
                     <ul id="q-nav-questions-list"></ul>
                </div>
            </div>
        `;

        tooltipElement = document.createElement('div');
        tooltipElement.id = 'q-nav-tooltip';
        document.body.appendChild(tooltipElement);

        sidebarElement.style.width = settings.width;
        if (!settings.isOpen) sidebarElement.classList.add('q-nav-hidden');
        sidebarElement.querySelector('#q-nav-toggle').innerHTML = settings.isOpen ? ICONS.open : ICONS.closed;

        addEventListeners();
        checkDarkMode();
    }

    function updateSidebar() {
        if (!sidebarElement) return;

        const convoId = getConversationId();
        const pinnedItems = loadPinnedItems(convoId);

        const pinnedList = sidebarElement.querySelector('#q-nav-pinned-list');
        const questionsList = sidebarElement.querySelector('#q-nav-questions-list');
        const pinnedSection = sidebarElement.querySelector('#q-nav-pinned-section');
        const questionsSection = sidebarElement.querySelector('#q-nav-questions-section');

        pinnedList.innerHTML = '';
        questionsList.innerHTML = '';

        questionElements = queryQuestionElements();
        const questionData = questionElements.map((el, index) => {
            const textContent = el.querySelector('.whitespace-pre-wrap')?.innerText.trim() || `Question ${index + 1}`;
            const answerEl = el.closest('article[data-turn-id]')?.nextElementSibling?.querySelector('[data-message-author-role="assistant"] .markdown.prose');
            const previewText = answerEl ? answerEl.innerText.trim().substring(0, 250) + (answerEl.innerText.length > 250 ? '...' : '') : '';
            return { el, index, textContent, previewText };
        });

        const pinnedData = [];
        const unpinnedData = [];

        questionData.forEach(item => {
            if (pinnedItems.includes(item.textContent)) {
                pinnedData.push(item);
            } else {
                unpinnedData.push(item);
            }
        });

        const renderItem = (item, isPinned) => {
            const listItem = document.createElement('li');
            listItem.textContent = item.textContent;
            listItem.dataset.index = item.index;
            listItem.dataset.text = item.textContent;
            listItem.dataset.preview = item.previewText;
            listItem.title = item.textContent;
            listItem.innerHTML = `${item.textContent}${ICONS.pin}`;

            if (isPinned) {
                listItem.classList.add('q-nav-pinned');
                pinnedList.appendChild(listItem);
            } else {
                questionsList.appendChild(listItem);
            }
        };

        pinnedData.forEach(item => renderItem(item, true));
        unpinnedData.forEach(item => renderItem(item, false));

        pinnedSection.style.display = pinnedData.length > 0 ? 'block' : 'none';
        questionsSection.style.display = unpinnedData.length > 0 ? 'block' : 'none';

        updateActiveHighlight();
    }

    function handleSidebarInteraction(event) {
        const target = event.target;

        if (target.closest('.q-nav-pin-icon')) {
            event.stopPropagation();
            const listItem = target.closest('li');
            const text = listItem.dataset.text;
            const convoId = getConversationId();
            let pinnedItems = loadPinnedItems(convoId);

            if (pinnedItems.includes(text)) {
                pinnedItems = pinnedItems.filter(item => item !== text);
            } else {
                pinnedItems.push(text);
            }
            savePinnedItems(convoId, pinnedItems);
            updateSidebar();
        } else if (target.closest('li')) {
            if (isResizing) return;
            const index = parseInt(target.closest('li').dataset.index, 10);
            if (!isNaN(index) && questionElements[index]) {
                questionElements[index].scrollIntoView({ behavior: 'smooth', block: 'start' });
                updateActiveHighlight(index);
            }
        }
    }

    const throttledUpdateActiveHighlight = throttle(updateActiveHighlight, 100);

    function updateActiveHighlight(forceIndex = null) {
        if (!sidebarElement || !scrollContainer) return;

        let newActiveIndex = -1;

        if (forceIndex !== null) {
            newActiveIndex = forceIndex;
        } else {
            const threshold = scrollContainer.getBoundingClientRect().top + 100;
            for (let i = questionElements.length - 1; i >= 0; i--) {
                if (questionElements[i].getBoundingClientRect().top <= threshold) {
                    newActiveIndex = i;
                    break;
                }
            }
            if (scrollContainer.scrollHeight - scrollContainer.scrollTop <= scrollContainer.clientHeight + 5) {
                newActiveIndex = questionElements.length - 1;
            }
        }

        if (newActiveIndex !== activeIndex) {
            activeIndex = newActiveIndex;
            const listItems = sidebarElement.querySelectorAll('#q-nav-list-wrapper li');
            let activeLi = null;
            listItems.forEach(li => {
                const isActive = parseInt(li.dataset.index) === activeIndex;
                li.classList.toggle('q-nav-active', isActive);
                if (isActive) activeLi = li;
            });

            if (activeLi) {
                activeLi.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
            }
        }
    }

    function addEventListeners() {
        const toggle = sidebarElement.querySelector('#q-nav-toggle');
        const resizer = sidebarElement.querySelector('#q-nav-resizer');
        const listWrapper = sidebarElement.querySelector('#q-nav-list-wrapper');

        toggle.addEventListener('click', () => {
            settings.isOpen = !settings.isOpen;
            sidebarElement.classList.toggle('q-nav-hidden');
            toggle.innerHTML = settings.isOpen ? ICONS.open : ICONS.closed;
            saveSettings();
        });

        resizer.addEventListener('mousedown', (e) => {
            e.preventDefault();
            isResizing = true;
            document.body.style.cursor = 'col-resize';
            document.body.style.userSelect = 'none';

            const startX = e.clientX;
            const startWidth = sidebarElement.offsetWidth;

            const doDrag = (dragEvent) => {
                const newWidth = startWidth - (dragEvent.clientX - startX);
                if (newWidth > 180 && newWidth < window.innerWidth * 0.5) {
                    settings.width = `${newWidth}px`;
                    sidebarElement.style.width = settings.width;
                }
            };
            const stopDrag = () => {
                document.removeEventListener('mousemove', doDrag);
                document.removeEventListener('mouseup', stopDrag);
                document.body.style.cursor = '';
                document.body.style.userSelect = '';
                saveSettings();
                setTimeout(() => { isResizing = false; }, 100);
            };
            document.addEventListener('mousemove', doDrag);
            document.addEventListener('mouseup', stopDrag);
        });

        listWrapper.addEventListener('click', handleSidebarInteraction);

        listWrapper.addEventListener('mouseover', e => {
            const li = e.target.closest('li');
            if (li && li.dataset.preview) {
                tooltipElement.textContent = li.dataset.preview;
                tooltipElement.style.opacity = '1';
            }
        });
        listWrapper.addEventListener('mouseout', () => {
            tooltipElement.style.opacity = '0';
        });
        listWrapper.addEventListener('mousemove', e => {
            if (tooltipElement.style.opacity === '1') {
                const tooltipRect = tooltipElement.getBoundingClientRect();
                let x = e.clientX + 15;
                let y = e.clientY + 15;

                if (x + tooltipRect.width > window.innerWidth - 10) {
                    x = e.clientX - tooltipRect.width - 15;
                }
                if (y + tooltipRect.height > window.innerHeight - 10) {
                    y = e.clientY - tooltipRect.height - 15;
                }

                tooltipElement.style.left = `${x}px`;
                tooltipElement.style.top = `${y}px`;
            }
        });

        scrollContainer = getChatContainer()?.parentElement;
        if (scrollContainer) {
            scrollContainer.addEventListener('scroll', throttledUpdateActiveHighlight);
        }
    }

    function checkDarkMode() {
        const isDark = document.documentElement.classList.contains('dark');
        sidebarElement?.classList.toggle('q-nav-dark', isDark);
        tooltipElement?.classList.toggle('q-nav-dark', isDark);
    }

    function initialize() {
        const chatContainer = getChatContainer();
        if (chatContainer && queryQuestionElements().length > 0) {
            if (!sidebarElement) {
                createSidebar();
            }
            updateSidebar();

            if (!pageObserver) {
                pageObserver = new MutationObserver(throttle(updateSidebar, 500));
                pageObserver.observe(chatContainer, { childList: true, subtree: true });
            }
        } else {
            destroy();
        }
    }

    function destroy() {
        if (sidebarElement) {
            sidebarElement.remove();
            sidebarElement = null;
        }
        if (tooltipElement) {
            tooltipElement.remove();
            tooltipElement = null;
        }
        if (pageObserver) {
            pageObserver.disconnect();
            pageObserver = null;
        }
        if (scrollContainer) {
            scrollContainer.removeEventListener('scroll', throttledUpdateActiveHighlight);
            scrollContainer = null;
        }
        questionElements = [];
        activeIndex = -1;
    }

    setInterval(() => {
        const newPath = location.pathname;
        const chatContainer = getChatContainer();

        if (newPath !== currentPath) {
            currentPath = newPath;
            destroy();
            setTimeout(initialize, 2000);
        } else if (!sidebarElement && chatContainer && queryQuestionElements().length > 0) {
            initialize();
        } else if (sidebarElement && !chatContainer) {
            destroy();
        }
    }, 500);

    themeObserver = new MutationObserver(checkDarkMode);
    themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
})();