ChatGPT Arrow Navigator

Add up/down arrow navigation for ChatGPT messages

// ==UserScript==
// @name         ChatGPT Arrow Navigator
// @version      1.0
// @description  Add up/down arrow navigation for ChatGPT messages
// @author       Bui Quoc Dung
// @match        https://chatgpt.com/*
// @grant        none
// @license      MIT
// @namespace    https://greasyfork.org/users/1485470
// ==/UserScript==

(() => {
  'use strict';

  /* ─── CONFIGURATION ────────────────────────────────────────────────── */
  const CONFIG = {
    SELECTORS: {
      userWrapper: 'div[data-message-author-role="user"]',
      composerTextarea: '#prompt-textarea',
      spaContainers: ['div.my-auto.text-base', 'div.m-auto.text-base', 'main'],
    },
    TIMINGS: {
      scrollSuppress: 300,
      debounce: 100,
      keepAlive: 5000,
      bootDelay: 300
    },
    CSS: `
      :root{--nav-bg:#ddd;--nav-fg:#333;}
      html.dark{--nav-bg:#444;--nav-fg:#eee;}
      .gm-anchor{scroll-margin-top:96px !important;}
      #gmNav{position:fixed;display:flex;z-index:1001;transition:top .15s ease-out, left .15s ease-out;}
      #gmNav .col{display:flex;flex-direction:column;align-items:center;gap:4px;}
      #gmNav button{all:unset;width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:var(--nav-bg);color:var(--nav-fg);cursor:pointer;}
      #gmNav span{color:var(--nav-fg);font-size:12px;font-weight:bold;line-height:1;min-width:2ch;text-align:center;}
    `
  };

  /* ─── HELPER FUNCTIONS ─────────────────────────────────────────────── */
  const $ = q => document.querySelector(q);
  const $$ = q => [...document.querySelectorAll(q)];

  /* ─── GLOBAL VARIABLES ─────────────────────────────────────────────── */
  let gmNav, upBtn, dnBtn, counter, obs, resizeObs, rebuildTimer;
  let curIdx = 0, suppress = false;

  /* ─── BUILD INTERFACE ──────────────────────────────────────────────── */
  function buildUI() {
    if ($('#gmNav')) return;

    gmNav = document.createElement('div'); gmNav.id = 'gmNav';
    gmNav.style.display = 'none';

    const col = document.createElement('div'); col.className = 'col';
    upBtn = document.createElement('button'); upBtn.textContent = '↑'; upBtn.title = 'Previous message';
    dnBtn = document.createElement('button'); dnBtn.textContent = '↓'; dnBtn.title = 'Next message';
    counter = document.createElement('span');

    col.append(upBtn, dnBtn, counter);
    gmNav.append(col);
    document.body.append(gmNav);

    window.addEventListener('resize', () => positionNav());
    upBtn.addEventListener('click', () => jump(curIdx - 1));
    dnBtn.addEventListener('click', () => jump(curIdx + 1));
  }

  /* ─── CORE LOGIC ───────────────────────────────────────────────────── */
  function userTurns() {
    return $$(CONFIG.SELECTORS.userWrapper);
  }

  function updateState() {
    const turns = userTurns();
    const isVisible = turns.length > 0;

    turns.forEach((turn, i) => {
      turn.id = `gm-q-${i}`;
      turn.classList.add('gm-anchor');
    });

    if (gmNav) gmNav.style.display = isVisible ? 'flex' : 'none';
    if (isVisible) counter.textContent = curIdx + 1;
  }

  function jump(idx) {
    const turns = userTurns();
    if (!turns.length) return;
    curIdx = Math.max(0, Math.min(idx, turns.length - 1));
    const targetNode = turns[curIdx];

    suppress = true;
    targetNode.scrollIntoView({ behavior: 'auto', block: 'start' });

    updateState();
    setTimeout(() => suppress = false, CONFIG.TIMINGS.scrollSuppress);
  }

  function syncFromScroll() {
    if (suppress) return;
    const turns = userTurns();
    if (!turns.length) return;

    const mid = window.innerHeight / 2;
    let best = 0;
    let bestDistance = Infinity;

    turns.forEach((node, i) => {
      const r = node.getBoundingClientRect();
      const distance = Math.abs(r.top + r.height / 2 - mid);
      if (distance < bestDistance) {
        bestDistance = distance;
        best = i;
      }
    });
    curIdx = best;
    updateState();
  }

  /* ─── POSITIONING & RESIZE OBSERVER ───────────────────────────────── */
  function positionNav(retry = true) {
    if (!gmNav) return;
    const textarea = $(CONFIG.SELECTORS.composerTextarea);
    const anchorEl = textarea?.closest('div.relative');

    if (!anchorEl) {
      if (retry) setTimeout(() => positionNav(false), 200);
      return;
    }

    const r = anchorEl.getBoundingClientRect();

    // --- POSITION ADJUSTMENTS ---
    const horizontalPadding = 12;
    const verticalOffset = 5;

    const t = r.top + (r.height - gmNav.offsetHeight) / 2 + verticalOffset;
    let l = r.left - gmNav.offsetWidth - horizontalPadding;

    if (l < horizontalPadding) l = r.right + horizontalPadding;

    gmNav.style.top = `${Math.max(4, t)}px`;
    gmNav.style.left = `${Math.max(4, l)}px`;
  }

  function observeComposerResize() {
    resizeObs?.disconnect();
    const anchorEl = $(CONFIG.SELECTORS.composerTextarea)?.closest('div.relative');
    if (anchorEl) {
      resizeObs = new ResizeObserver(() => positionNav(false));
      resizeObs.observe(anchorEl);
    }
  }

  /* ─── BOOTSTRAP & PAGE CHANGE OBSERVER ────────────────────────────── */
  let lastUrl = location.href;

  function boot() {
    if (location.href !== lastUrl) {
      lastUrl = location.href;
      curIdx = 0;
    }

    const root = CONFIG.SELECTORS.spaContainers.map($).find(Boolean);
    if (!root) return;

    buildUI();
    observeComposerResize();
    root.addEventListener('scroll', syncFromScroll, { passive: true });
    window.addEventListener('scroll', syncFromScroll, { passive: true });

    obs?.disconnect();
    obs = new MutationObserver(() => {
      clearTimeout(rebuildTimer);
      rebuildTimer = setTimeout(() => { updateState(); positionNav(); }, CONFIG.TIMINGS.debounce);
    });
    obs.observe(root, { childList: true, subtree: true });

    updateState();
    syncFromScroll();
    positionNav();
  }

  document.head.appendChild(Object.assign(document.createElement('style'), { textContent: CONFIG.CSS }));
  ['pushState', 'replaceState'].forEach(fn => {
    const original = history[fn];
    history[fn] = function() {
      const result = original.apply(this, arguments);
      setTimeout(boot, CONFIG.TIMINGS.bootDelay);
      return result;
    };
  });
  window.addEventListener('popstate', () => setTimeout(boot, CONFIG.TIMINGS.bootDelay));
  window.addEventListener('DOMContentLoaded', () => setTimeout(boot, CONFIG.TIMINGS.bootDelay));
  setInterval(() => $('#gmNav') || boot(), CONFIG.TIMINGS.keepAlive);

})();