ChatGPT Virtual Scroll

Optimizes long ChatGPT threads by virtualizing old conversation turns

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name         ChatGPT Virtual Scroll
// @namespace    https://github.com/9ghtX/ChatGPT-Virtual-Scroll
// @version      2.2.0
// @description  Optimizes long ChatGPT threads by virtualizing old conversation turns
// @author       9ghtX
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @grant        none
// @homepageURL  https://github.com/9ghtX/ChatGPT-Virtual-Scroll
// @supportURL   https://github.com/9ghtX/ChatGPT-Virtual-Scroll/issues
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  // ---------------- settings ----------------
const MAX_TURNS = 6;
const MIN_TURNS = 4;
const PRUNE_BATCH = 1;
const RESTORE_BATCH = 1;

const RESTORE_ANCHOR_INDEX = 1;
const PRUNE_ANCHOR_INDEX = 3;

  const INIT_TO_BOTTOM = true;
  const DEBUG = false;

  const SUPPRESS_DIR_AFTER_PRUNE_MS = 120;
  const SUPPRESS_DIR_AFTER_RESTORE_MS = 140;
  const PRUNE_LOCK_AFTER_RESTORE_MS = 220;
  const RESTORE_LOCK_AFTER_PRUNE_MS = 120;

  // ---------------- state ----------------
  let scroller = null;
  let root = null;
  let ticking = false;
  let internalMutation = false;
  let initialized = false;
  let lastScrollTop = 0;

  let suppressDirectionUntil = 0;
  let pruneLockUntil = 0;
  let restoreLockUntil = 0;

  let userIntent = "idle";
  let userIntentUntil = 0;

  // Буфер удалённых сверху turn'ов:
  // [самый старый сверху, ..., самый новый удалённый]
  const topBuffer = [];

  // ---------------- utils ----------------
  function log(...args) {
    if (DEBUG) console.log("[virtual-scroll]", ...args);
  }

  function now() {
    return performance.now();
  }

  function isElement(node) {
    return node && node.nodeType === Node.ELEMENT_NODE;
  }

  function isTurn(node) {
    return isElement(node) && node.matches("section[data-testid^='conversation-turn']");
  }

  function isScrollable(el) {
    if (!el || el === document.documentElement) return false;
    const cs = getComputedStyle(el);
    const oy = cs.overflowY;
    const canScroll = oy === "auto" || oy === "scroll";
    return canScroll && el.scrollHeight > el.clientHeight + 2;
  }

  function findScrollerFromTurn(turn) {
    let el = turn;
    while (el && el !== document.documentElement) {
      if (isScrollable(el)) return el;
      el = el.parentElement;
    }
    return document.scrollingElement || document.documentElement;
  }

  function getTurns() {
    if (!root) return [];
    return [...root.children].filter(isTurn);
  }

  function getScrollerRect() {
    return scroller.getBoundingClientRect();
  }

  function outerHeight(el) {
    const cs = getComputedStyle(el);
    return (
      el.getBoundingClientRect().height +
      parseFloat(cs.marginTop || 0) +
      parseFloat(cs.marginBottom || 0)
    );
  }

  function getAnchorTurn() {
    const turns = getTurns();
    if (!turns.length) return null;

    const scrollerTop = getScrollerRect().top;

    for (const t of turns) {
      const r = t.getBoundingClientRect();
      if (r.bottom > scrollerTop + 1) return t;
    }

    return turns[turns.length - 1] || null;
  }

  function getAnchorInfo() {
    const turns = getTurns();
    if (!turns.length) return null;

    const anchor = getAnchorTurn();
    if (!anchor) return null;

    return {
      anchor,
      turns,
      index: turns.indexOf(anchor),
      top: anchor.getBoundingClientRect().top,
      bottom: anchor.getBoundingClientRect().bottom,
    };
  }

  function captureAnchor() {
    const anchor = getAnchorTurn();
    if (!anchor) return null;
    return {
      el: anchor,
      top: anchor.getBoundingClientRect().top,
    };
  }

  function restoreAnchor(anchorSnapshot) {
    if (!anchorSnapshot || !anchorSnapshot.el || !anchorSnapshot.el.isConnected) return;
    const afterTop = anchorSnapshot.el.getBoundingClientRect().top;
    const delta = afterTop - anchorSnapshot.top;
    if (delta !== 0) {
      scroller.scrollTop += delta;
    }
  }

  function withInternalMutation(fn) {
    internalMutation = true;
    try {
      return fn();
    } finally {
      internalMutation = false;
    }
  }

  function scrollToBottom() {
    requestAnimationFrame(() => {
      scroller.scrollTop = scroller.scrollHeight;
      lastScrollTop = scroller.scrollTop;
    });
  }

  function setUserIntent(dir, ttl = 220) {
    userIntent = dir;
    userIntentUntil = now() + ttl;
  }

  function getUserIntent() {
    return now() < userIntentUntil ? userIntent : "idle";
  }

  function suppressDirection(ms) {
    suppressDirectionUntil = now() + ms;
    lastScrollTop = scroller.scrollTop;
  }

  function lockPrune(ms) {
    pruneLockUntil = now() + ms;
  }

  function lockRestore(ms) {
    restoreLockUntil = now() + ms;
  }

  function isPruneLocked() {
    return now() < pruneLockUntil;
  }

  function isRestoreLocked() {
    return now() < restoreLockUntil;
  }

  function getDirection() {
    const intent = getUserIntent();
    if (intent !== "idle") {
      lastScrollTop = scroller.scrollTop;
      return intent;
    }

    if (now() < suppressDirectionUntil) {
      lastScrollTop = scroller.scrollTop;
      return "idle";
    }

    const current = scroller.scrollTop;
    const dir =
      current < lastScrollTop ? "up" :
      current > lastScrollTop ? "down" :
      "idle";

    lastScrollTop = current;
    return dir;
  }

  // ---------------- root detection ----------------
  function findRoot(anyTurn) {
    if (!anyTurn) return null;

    let node = anyTurn.parentElement;
    let best = node;

    while (node && node !== document.body) {
      const directTurnChildren = [...node.children].filter(isTurn).length;
      if (directTurnChildren >= 1) {
        best = node;
      }

      const parent = node.parentElement;
      if (!parent) break;

      const parentDirectTurnChildren = [...parent.children].filter(isTurn).length;

      if (parentDirectTurnChildren >= directTurnChildren && parentDirectTurnChildren > 0) {
        node = parent;
      } else {
        break;
      }
    }

    return best;
  }

  // ---------------- decision logic ----------------
  function shouldRestoreTop() {
    if (!topBuffer.length) return false;
    if (isRestoreLocked()) return false;

    const info = getAnchorInfo();
    if (!info) return false;

    return info.index <= RESTORE_ANCHOR_INDEX;
  }

  function shouldPruneTop() {
    if (isPruneLocked()) return false;

    const info = getAnchorInfo();
    if (!info) return false;

    if (info.turns.length <= MAX_TURNS) return false;
    return info.index >= PRUNE_ANCHOR_INDEX;
  }

  // ---------------- prune / restore ----------------
  function pruneTop() {
    if (!shouldPruneTop()) return false;

    const info = getAnchorInfo();
    if (!info) return false;

    const turns = info.turns;
    const anchor = info.anchor;
    const anchorIndex = info.index;

    const maxRemovableBeforeAnchor = Math.max(0, anchorIndex - RESTORE_ANCHOR_INDEX);
    const excess = turns.length - MIN_TURNS;
    const count = Math.min(PRUNE_BATCH, maxRemovableBeforeAnchor, excess);

    if (count <= 0) return false;

    const anchorSnapshot = captureAnchor();
    const removed = [];

    withInternalMutation(() => {
      for (let i = 0; i < count; i++) {
        const t = turns[i];
        if (!t || t === anchor) break;
        removed.push(t);
        t.remove();
      }
    });

    if (!removed.length) return false;

    for (const t of removed) {
      topBuffer.push(t);
    }

    restoreAnchor(anchorSnapshot);
    suppressDirection(SUPPRESS_DIR_AFTER_PRUNE_MS);
    lockRestore(RESTORE_LOCK_AFTER_PRUNE_MS);

    log("pruneTop", {
      removed: removed.length,
      topBuffer: topBuffer.length,
      turnsNow: getTurns().length,
      anchorIndexBefore: anchorIndex,
    });

    return true;
  }

  function restoreTop() {
    if (!shouldRestoreTop()) return false;

    const info = getAnchorInfo();
    if (!info) return false;

    const count = Math.min(RESTORE_BATCH, topBuffer.length);
    if (count <= 0) return false;

    const anchorSnapshot = captureAnchor();
    const toInsert = [];

    for (let i = 0; i < count; i++) {
      const node = topBuffer.pop();
      if (!node) break;
      toInsert.push(node);
    }

    if (!toInsert.length) return false;

    toInsert.reverse();

    withInternalMutation(() => {
      let insertBeforeNode = getTurns()[0] || null;
      for (const node of toInsert) {
        root.insertBefore(node, insertBeforeNode);
      }
    });

    restoreAnchor(anchorSnapshot);
    suppressDirection(SUPPRESS_DIR_AFTER_RESTORE_MS);
    lockPrune(PRUNE_LOCK_AFTER_RESTORE_MS);

    log("restoreTop", {
      restored: toInsert.length,
      topBuffer: topBuffer.length,
      turnsNow: getTurns().length,
      anchorIndexBefore: info.index,
    });

    return true;
  }

  function trimToWindowImmediately() {
    let guard = 0;
    while (guard < 200 && shouldPruneTop()) {
      if (!pruneTop()) break;
      guard++;
    }

    log("trimToWindowImmediately", {
      guard,
      turnsNow: getTurns().length,
      topBuffer: topBuffer.length,
    });
  }

  // ---------------- sync ----------------
  function sync() {
    if (!initialized || !root || !scroller) return;

    const direction = getDirection();
    const turns = getTurns();
    if (!turns.length) return;

    if (direction === "up") {
      restoreTop();
      return;
    }

    if (direction === "down") {
      pruneTop();
      return;
    }

    // idle: мягкая стабилизация, но только prune
    if (turns.length > MAX_TURNS + 2) {
      pruneTop();
    }
  }

  function onScroll() {
    if (ticking) return;
    ticking = true;

    requestAnimationFrame(() => {
      try {
        sync();
      } finally {
        ticking = false;
      }
    });
  }

  // ---------------- observers ----------------
  function observeRoot() {
    const mo = new MutationObserver((mutations) => {
      if (internalMutation) return;

      let hasRelevantChange = false;

      for (const m of mutations) {
        for (const n of m.addedNodes) {
          if (isTurn(n) || (isElement(n) && n.querySelector?.("section[data-testid^='conversation-turn']"))) {
            hasRelevantChange = true;
            break;
          }
        }
        if (hasRelevantChange) break;

        for (const n of m.removedNodes) {
          if (isTurn(n) || (isElement(n) && n.querySelector?.("section[data-testid^='conversation-turn']"))) {
            hasRelevantChange = true;
            break;
          }
        }
        if (hasRelevantChange) break;
      }

      if (!hasRelevantChange) return;

      requestAnimationFrame(() => {
        const turns = getTurns();
        if (turns.length > MAX_TURNS + 2) {
          pruneTop();
        }
      });
    });

    mo.observe(root, { childList: true, subtree: false });
  }

  // ---------------- CSS perf ----------------
  function injectCSS() {
    const st = document.createElement("style");
    st.textContent = `
      section[data-testid^="conversation-turn"] {
        content-visibility: auto;
        contain-intrinsic-size: 800px;
      }
    `;
    document.head.appendChild(st);
  }

  // ---------------- init ----------------
  function init() {
    const anyTurn = document.querySelector("section[data-testid^='conversation-turn']");
    if (!anyTurn) return false;

    scroller = findScrollerFromTurn(anyTurn);
    root = findRoot(anyTurn);

    if (!scroller || !root) return false;

    injectCSS();

    scroller.addEventListener("scroll", onScroll, { passive: true });

    scroller.addEventListener("wheel", (e) => {
      if (e.deltaY < 0) setUserIntent("up");
      else if (e.deltaY > 0) setUserIntent("down");
    }, { passive: true });

    window.addEventListener("keydown", (e) => {
      const k = e.key;
      if (k === "ArrowUp" || k === "PageUp" || k === "Home") {
        setUserIntent("up", 300);
      } else if (k === "ArrowDown" || k === "PageDown" || k === "End" || k === " ") {
        setUserIntent("down", 300);
      }
    }, { passive: true });

    observeRoot();

    initialized = true;
    lastScrollTop = scroller.scrollTop;

    if (INIT_TO_BOTTOM) {
      setTimeout(() => {
        scrollToBottom();
        requestAnimationFrame(() => {
          trimToWindowImmediately();
        });
      }, 300);
    } else {
      requestAnimationFrame(() => {
        trimToWindowImmediately();
      });
    }

    log("initialized", { scroller, root });
    return true;
  }

  const timer = setInterval(() => {
    if (init()) clearInterval(timer);
  }, 400);
})();