ChatGPT Message Navigator

Seamlessly browse ChatGPT conversation messages with handy up/down buttons and highlighting

// ==UserScript==
// @name               ChatGPT Message Navigator
// @name:de            ChatGPT Nachrichten-Navigator
// @namespace          https://greasyfork.org/users/928242
// @version            1.0.3
// @description        Seamlessly browse ChatGPT conversation messages with handy up/down buttons and highlighting
// @description:de     Bequem durch ChatGPT-Nachrichten navigieren mit Auf-/Ab-Tasten und Hervorhebung
// @author             Kamikaze (https://github.com/Kamiikaze)
// @supportURL         https://github.com/Kamiikaze/Tampermonkey/issues
// @icon               https://www.google.com/s2/favicons?sz=64&domain=chatgpt.com
// @match              https://chatgpt.com/c/*
// @grant              none
// @license            MIT
// ==/UserScript==

(function() {
    'use strict';

    let currentIndex = 0;
    let previousElement = null;
    let highlightTimeout = null;
    const HIGHLIGHT_DURATION = 1;

    console.log('[Navigator] Initializing script');

    // Inject shared styles
    const style = document.createElement('style');
    style.textContent = `
        .navigator-ui { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; position: relative; z-index: 1000; }
        .navigator-ui button, .navigator-ui span { font-family: sans-serif; }
        .navigator-ui button { padding: 4px 11px; border-radius: 4px; cursor: pointer; transition: background-color .3s, color .3s, border-color .3s; }
        .navigator-ui span { font-size: 12px; }
        .navigator-ui.dark button { background-color: #333; color: #eee; border: 1px solid #555; }
        .navigator-ui.dark button:hover { background-color: rgba(255,255,255,0.16); }
        .navigator-ui.dark span { color: #ccc; }
        .navigator-ui.light button { background-color: #fff; color: #333; border: 1px solid #888; }
        .navigator-ui.light button:hover { background-color: rgba(0,0,0,0.16); }
        .navigator-ui.light span { color: #444; }
        .navigator-highlight { box-shadow: 0 0 0 2px var(--highlight-color) inset; transition: box-shadow 0.5s ease-in-out; }
    `;
    document.head.appendChild(style);

    function getTurns() {
        const turns = Array.from(document.querySelectorAll('article[data-testid^="conversation-turn"]'));
        console.log(`[Navigator] Found ${turns.length} turns`);
        return turns;
    }

    function getTheme() {
        const theme = window.localStorage.theme === 'dark' || document.documentElement.classList.contains('dark') ? 'dark' : 'light';
        console.log(`[Navigator] Current theme: ${theme}`);
        return theme;
    }

    function getHighlightColor() {
        const color = getTheme() === 'dark'
            ? 'rgba(38,198,218,0.6)'
            : 'rgba(255,235,59,0.6)';
        console.log(`[Navigator] Highlight color: ${color}`);
        return color;
    }

    function highlight(node) {
        clearTimeout(highlightTimeout);
        console.log('[Navigator] Highlighting element', node);
        if (previousElement) {
            previousElement.classList.remove('navigator-highlight');
            previousElement.style.boxShadow = '';
        }
        node.classList.add('navigator-highlight');
        node.style.boxShadow = `0 0 0 2px ${getHighlightColor()} inset`;
        previousElement = node;
        highlightTimeout = setTimeout(() => {
            console.log('[Navigator] Clearing highlight');
            node.style.boxShadow = '';
            previousElement = null;
        }, HIGHLIGHT_DURATION*2000);
    }

    let counterSpan;
    function updateCounter() {
        const turns = getTurns();
        counterSpan.textContent = `${turns.length ? currentIndex + 1 : 0} / ${turns.length}`;
        console.log(`[Navigator] Updated counter to ${counterSpan.textContent}`);
    }

    function navigateTo(index) {
        console.log(`[Navigator] navigateTo index ${index}`);
        const turns = getTurns(); if (!turns.length) return;
        currentIndex = Math.max(0, Math.min(index, turns.length - 1));
        console.log(`[Navigator] Setting currentIndex to ${currentIndex}`);
        const target = turns[currentIndex];
        target.scrollIntoView({ behavior: 'smooth', block: 'start' });
        target.setAttribute('tabindex', '-1');
        target.focus({ preventScroll: true });
        highlight(target);
        updateCounter();
    }

    function createNavigationUI() {
        console.log('[Navigator] Creating navigation UI');
        const parentContainer = document.querySelector('#thread-bottom-container div[class*="--thread-content-max-width"]');
        if (!parentContainer) {
            console.log('[Navigator] parentContainer not found');
            return;
        }
        const container = document.createElement('div');
        container.className = `navigator-ui ${getTheme()}`;

        const up = document.createElement('button'); up.textContent = '↑';
        const down = document.createElement('button'); down.textContent = '↓';
        up.addEventListener('click', () => navigateTo(currentIndex - 1));
        down.addEventListener('click', () => navigateTo(currentIndex + 1));
        counterSpan = document.createElement('span');

        container.append(up, counterSpan, down);
        parentContainer.appendChild(container);

        currentIndex = getTurns().length - 1;
        console.log(`[Navigator] Initial currentIndex set to ${currentIndex}`);
        updateCounter();
    }

    function resetNavigation() {
        console.log('[Navigator] Reset navigation state');
        currentIndex = getTurns().length - 1;
        if (previousElement) {
            previousElement.classList.remove('navigator-highlight');
            previousElement.style.boxShadow = '';
        }
        previousElement = null;
        clearTimeout(highlightTimeout);
        updateCounter();
    }

    ['pushState','replaceState'].forEach(m => {
        const orig = history[m]; history[m] = function() { orig.apply(this, arguments); console.log(`[Navigator] history.${m} called`); window.dispatchEvent(new Event('navigation-changed')); };
    });
    window.addEventListener('popstate', () => { console.log('[Navigator] popstate'); window.dispatchEvent(new Event('navigation-changed')); });
    window.addEventListener('navigation-changed', resetNavigation);

    const observer = new MutationObserver(muts => {
        muts.forEach(m => m.addedNodes.forEach(node => {
            if (node.nodeType === 1 && node.matches('article[data-testid^="conversation-turn"]')) {
                console.log('[Navigator] New conversation-turn detected');
                resetNavigation();
            }
        }));
    });
    observer.observe(document.body, { childList: true, subtree: true });

    (function initOnMessages() {
        console.log('[Navigator] Waiting for initial messages');
        if (getTurns().length) {
            createNavigationUI();
            return;
        }
        const initObserver = new MutationObserver((_, obs) => {
            if (getTurns().length) {
                console.log('[Navigator] Initial messages loaded');
                createNavigationUI();
                obs.disconnect();
            }
        });
        initObserver.observe(document.body, { childList: true, subtree: true });
    })();
})();