| // ==UserScript== | | | // @name ChatGPT Large Chat Performance | | | // @namespace local.chatgpt.performance | | | // @version 0.4.0 | | | // @description Makes very large ChatGPT conversations less sluggish with CSS containment and detachable turbo scrolling. | | | // @match https://chatgpt.com/* | | | // @match https://chat.openai.com/* | | | // @run-at document-idle | | | // @grant none | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| const STYLE_ID = 'tm-chatgpt-large-chat-performance-style'; | |
| const HEIGHT_VAR = '--tm-cgpt-turn-height'; | |
| const TURN_SELECTOR = '[data-turn-id-container]'; | |
| const SCROLL_IDLE_MS = 180; | |
| let enabled = true; | |
| let root = null; | |
| let ro = null; | |
| let mo = null; | |
| let scanTimer = 0; | |
| let scrollIdleTimer = 0; | |
| let parked = false; | |
| let parkedNodes = []; | |
| let placeholder = null; | |
| const seen = new WeakSet(); | |
| function addStyles() { | |
| if (document.getElementById(STYLE_ID)) return; | |
| const style = document.createElement('style'); | |
| style.id = STYLE_ID; | |
| style.textContent = ` | |
| ${TURN_SELECTOR} { | |
| content-visibility: auto !important; | |
| contain: layout style paint !important; | | | contain-intrinsic-size: auto var(${HEIGHT_VAR}, 760px) !important; | | | } | | | .markdown, | | | .text-message, | | | pre, | | | code { | | | overflow-wrap: anywhere !important; | | | } | | | video, | | | canvas, | | | iframe { | |
| content-visibility: auto !important; | |
| contain: layout paint !important; | |
| } | |
| #tm-cgpt-thread-placeholder { | |
| display: block !important; | |
| contain: strict !important; | |
| visibility: hidden !important; | |
| pointer-events: none !important; | |
| } | | | `; | | | document.documentElement.appendChild(style); | | | } | |
| function findThreadScrollRoot() { | |
| const thread = document.querySelector('#thread'); | |
| for (let el = thread?.parentElement; el; el = el.parentElement) { | |
| const style = getComputedStyle(el); | |
| if (/(auto|scroll|overlay)/.test(style.overflowY) && el.scrollHeight > el.clientHeight + 200) { | |
| return el; | | | } | | | } | | | return document.scrollingElement || document.documentElement; | | | } | |
| function rememberHeight(entry) { | |
| const height = Math.ceil(entry.borderBoxSize?.[0]?.blockSize || entry.contentRect.height); | |
| if (height > 40) entry.target.style.setProperty(HEIGHT_VAR, `${height}px`); | |
| } | |
| function observeTurn(turn) { | |
| if (seen.has(turn)) return; | |
| seen.add(turn); | |
| ro.observe(turn); | |
| } | |
| function scanTurns() { | |
| if (!enabled || !ro) return; | |
| document.querySelectorAll(TURN_SELECTOR).forEach(observeTurn); | |
| } | |
| function scheduleScan() { | |
| clearTimeout(scanTimer); | |
| scanTimer = window.setTimeout(scanTurns, 150); | |
| } | |
| function getThread() { | |
| return document.querySelector('#thread'); | |
| } | |
| function parkThread() { | |
| if (parked) return; | |
| const thread = getThread(); | |
| if (!thread || !thread.childNodes.length) return; | |
| const height = Math.max(thread.scrollHeight, thread.getBoundingClientRect().height, 1000); | |
| placeholder = document.createElement('div'); | |
| placeholder.id = 'tm-cgpt-thread-placeholder'; | |
| placeholder.style.minHeight = `${Math.ceil(height)}px`; | |
| parkedNodes = [...thread.childNodes]; | |
| thread.replaceChildren(placeholder); | |
| thread.style.minHeight = `${Math.ceil(height)}px`; | |
| parked = true; | |
| } | |
| function restoreThread() { | |
| if (!parked) return; | |
| const thread = getThread(); | |
| if (thread) { | |
| thread.replaceChildren(...parkedNodes); | |
| thread.style.minHeight = ''; | |
| } | |
| parkedNodes = []; | |
| placeholder = null; | |
| parked = false; | |
| scheduleScan(); | |
| } | |
| function onScroll() { | |
| parkThread(); | |
| clearTimeout(scrollIdleTimer); | |
| scrollIdleTimer = window.setTimeout(restoreThread, SCROLL_IDLE_MS); | |
| } | |
| function start() { | |
| stop(false); | |
| enabled = true; | |
| addStyles(); | |
| root = findThreadScrollRoot(); | |
| ro = new ResizeObserver((entries) => entries.forEach(rememberHeight)); | |
| scanTurns(); | |
| mo = new MutationObserver(scheduleScan); | |
| mo.observe(document.body, { childList: true, subtree: true }); | |
| root?.addEventListener('scroll', onScroll, { passive: true }); | |
| } | |
| function stop(removeStyles = true) { | |
| root?.removeEventListener('scroll', onScroll); | |
| ro?.disconnect(); | |
| mo?.disconnect(); | |
| root = null; | |
| ro = null; | |
| mo = null; | |
| clearTimeout(scanTimer); | |
| clearTimeout(scrollIdleTimer); | |
| restoreThread(); | |
| if (removeStyles) document.getElementById(STYLE_ID)?.remove(); | |
| } | |
| function toggle() { | |
| enabled = !enabled; | |
| if (enabled) start(); | |
| else stop(); | |
| console.info(`[ChatGPT Large Chat Performance] ${enabled ? 'enabled' : 'disabled'}`); | |
| } | |
| window.addEventListener('keydown', (event) => { | |
| if (event.altKey && event.code === 'KeyV') toggle(); | |
| }); | |
| start(); | |
| })(); |