ChatGPT Virtual Scroll

Optimizes long ChatGPT threads by virtualizing old conversation turns

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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);
})();