ChatGPT Question Sidebar Navigation

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

Version au 08/10/2025. Voir la dernière version.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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