ChatGPT Question Sidebar Navigation

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

As of 08.10.2025. See апошняя версія.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        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'] });
})();