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