ChatGPT Collapse Messages

Collapse messages by clicking the bottom bar. Autocollapse on open. Remembers collapse state. Add bar colors and separators. Mobile: max size for the textarea when not focused.

// ==UserScript==
// @name         ChatGPT Collapse Messages
// @description  Collapse messages by clicking the bottom bar. Autocollapse on open. Remembers collapse state. Add bar colors and separators. Mobile: max size for the textarea when not focused.
// @version      1.9
// @author       C89sd
// @namespace    https://greasyfork.org/users/1376767
// @match        https://chatgpt.com/*
// @grant        GM_addStyle
// @run-at       document-start
// @noframes
// ==/UserScript==

'use strict';

const ALPHA = "04"; // 0xFF, controls the highlight intensity. "0F" best on mobile

/* __Hierarchy__:
article(maybe .text-collapsed)
> .text-base
  > div
    > .text-message (main message)
    > .z-0 (bottom bar)
    > other
*/

GM_addStyle(`
/* Mobile fix: remove the white ring on open. */
button.focus\\:ring-white {
  box-shadow: none !important;
}
@media (pointer: coarse) {
  /* Mobile fix: smaller header. */
  :root { --header-height: calc(var(--spacing)*8) !important; }

  /* Mobile fix: full width messages */
   :root { --user-chat-width: 90% !important; }

  /* Mobile fix: 10px margin */
  .text-base {
    padding-left: 10px;
    padding-right: 10px;
  }
  /* Mobile fix: clicking the "Thinking mode Standard/Extended" pill closes it, raise the left side */
  button.__composer-pill {
    z-index: 10;
    pointer-events: auto;
  }
  /* Mobile fix: bottom bar buttons are transparent/clickthrough */
  article button {
    pointer-events: auto;
  }
  /* Mobile fix: minimise the textarea when not focused to save space. */
  html:not(:has(input:focus, textarea:focus, [contenteditable="true"]:focus)) #prompt-textarea {
    max-height: 2.6em !important;
    margin-top: 0 !important;
    padding-bottom: 5px !important;
  }
}

article {
  padding-bottom: 10px !important;
}
article .text-base {
  padding-top: 0px;
}

article.text-collapsed .text-base > div > div:has(.text-message) {
  max-height: 6em !important;
  overflow: hidden;

  --fade: 4.5em;
  --mask: linear-gradient(
    to bottom,
    #000 0,
    #000 calc(100% - var(--fade)),
    transparent 100%
  );
  -webkit-mask: var(--mask) no-repeat;
  mask: var(--mask) no-repeat;
}

article .text-base > div {
  border-bottom: solid 10px #8884;
}
article[data-turn="user"] .text-base > div {
  border-bottom: solid 2px #8884;
}

article .text-base > div > div.z-0  {
  background: linear-gradient(to bottom, transparent 0%, #00FF00${ALPHA} 100%);
}
article.text-collapsed .text-base > div > div.z-0  {
  background: linear-gradient(to bottom, transparent 0%, #FF0000${ALPHA} 100%);
}
`);

const ARTICLE_SELECTOR = 'article';

const LS_KEY = 'article_state_db_v1';
const STATE = { COLLAPSED: 0, EXPANDED: 1 };
const DB_CACHE_MS = 500;
let dbCache = null;
let dbCacheAt = 0;

// Debounce DB parse
function loadStateDB() {
  const now = Date.now();
  if (dbCache && (now - dbCacheAt) < DB_CACHE_MS) return dbCache;
  try {
    const raw = localStorage.getItem(LS_KEY);
    dbCache = raw ? JSON.parse(raw) : {};
    if (typeof dbCache !== 'object' || Array.isArray(dbCache) || dbCache === null) dbCache = {};
  } catch {
    dbCache = {};
  }
  dbCacheAt = now;
  return dbCache;
}
function saveStateDB(db) {
  dbCache = db;
  dbCacheAt = Date.now();
  try { localStorage.setItem(LS_KEY, JSON.stringify(db)); } catch {}
}
function hasId(id) {
  const db = loadStateDB();
  return Object.prototype.hasOwnProperty.call(db, id);
}
function setIdState(id, stateEnum) {
  const db = loadStateDB();
  db[id] = stateEnum;
  saveStateDB(db);
}

const scrollParent = el => {
  for (let e = el; e; e = e.parentElement) {
    const s = getComputedStyle(e);
    if (/(auto|scroll|overlay)/.test(s.overflowY) && e.scrollHeight > e.clientHeight) return e;
  }
  return document.scrollingElement || document.documentElement;
};

// Note: 'data-turn-id' cannot be trusted for new messages so use 'convid#testid'
function getId(article) {
  let convid = location.pathname.match(/\/c\/([\-a-zA-Z0-9]+)/)?.[1];
  let testid = article.getAttribute('data-testid')?.match(/(\d+)/)?.[1];
  if (convid && testid) return convid+'#'+testid;
  return null
}

function doArticle(article, onLoad) {
  const text = article.querySelector('.text-message');
  if (!text) return;

  const id = getId(article);

  // On load: consult DB and apply initial state
  if (id && !id.startsWith('request-')) {
    const db = loadStateDB();
    // console.log(article, text, id, db[id])
    if (onLoad) {
      if (!hasId(id)) {
        // collapse all on open
        setTimeout(() => { article.classList.add('text-collapsed'); }, 200);
        setIdState(id, STATE.COLLAPSED);
      } else if (db[id] === STATE.COLLAPSED) {
        setTimeout(() => { article.classList.add('text-collapsed'); }, 200);
      }
    } else {
        // New messages
        const user = (article.getAttribute('data-turn') === 'user');
        setIdState(id, user ? STATE.COLLAPSED : STATE.EXPANDED);
    }
  }

  // Click: toggle and persist new state
  article.addEventListener('click', (e) => {
    const INSIDE_BOTTOM_BAR = '.text-base > div > .z-0';
    if (!(e?.target?.closest(INSIDE_BOTTOM_BAR))) return;
    if (e?.target?.closest('button')) return; // ignore buttons on the bar

    const sc = scrollParent(e.currentTarget || article);
    const adding = !article.classList.contains('text-collapsed'); // about to collapse
    const fromBottom = adding ? (sc.scrollHeight - sc.scrollTop - sc.clientHeight) : 0;

    article.classList.toggle('text-collapsed');

    if (adding) requestAnimationFrame(() => {
      sc.scrollTop = Math.max(0, sc.scrollHeight - sc.clientHeight - fromBottom);
    });

    if (id) setIdState(id, adding ? STATE.COLLAPSED : STATE.EXPANDED);
    e.stopPropagation();
  }, true);
}

const observer = new MutationObserver(function(mutations) {
  mutations.forEach(function(mutation) {
    if (mutation.type === 'childList') {
      mutation.addedNodes.forEach(function(node) {
        if (node.nodeType === Node.ELEMENT_NODE) {

          // Check for articles
          if (node.matches(ARTICLE_SELECTOR)) {
            // console.log("ONLOAD", node)
            doArticle(node, false);
          }
          node.querySelectorAll(ARTICLE_SELECTOR).forEach(descendantArticle => {
            // console.log("LIVE", descendantArticle)
            doArticle(descendantArticle, true);
          });

        }
      });
    }
  });
});

observer.observe(document.documentElement, { childList: true, subtree: true });