ChatGPT Virtual Scroll

Optimizes long ChatGPT threads by virtualizing old conversation turns

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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