Optimizes long ChatGPT threads by virtualizing old conversation turns
// ==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);
})();