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.

目前為 2025-10-06 提交的版本,檢視 最新版本

// ==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.2
// @author       C89sd
// @namespace    https://greasyfork.org/users/1376767
// @match        https://chatgpt.com/*
// @grant        GM_addStyle
// @run-at       document-start
// @noframes
// ==/UserScript==

'use strict';

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

// Collapse on mobile
GM_addStyle(`
@media (pointer: coarse) {
  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;
}
/*
.text-base > div > div.z-0  {
  border-top: dashed 1px #8884;
}
*/
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%, #00FF0004 100%);
}
article.text-collapsed .text-base > div > div.z-0  {
  background: linear-gradient(to bottom, transparent 0%, #FF000004 100%);
}
`);

// const PROMPT_TEXTAREA_SELECTOR = '#prompt-textarea';
// function doTextarea(element) {
// }

// Collapse on load
// Click on bottom bar to toggle


const ARTICLE_SELECTOR = 'article';

const LS_KEY = 'article_state_db_v2';
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) {
  if (id.startsWith('request-')) return;
  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;
};

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) {
    const db = loadStateDB();
    // console.log(article, text, id, onLoad, db[id])
    if (onLoad) {
      if (db[id] === STATE.COLLAPSED) {
        setTimeout(() => { article.classList.add('text-collapsed'); }, 200);
      }
      if (!hasId(id)) {
        // collapse all when opening old room
        setTimeout(() => { article.classList.add('text-collapsed'); }, 200);
        setIdState(id, STATE.COLLAPSED);
      }
    } else {
       // #Bug : data-turn-id cannot be trusted, use convid#testid
        setIdState(id, STATE.EXPANDED); // set new messages expanded

        // console.log(" ---- REAPPLYING ALL")
        // // #Bug we can update the state of previous new messages hough
        // document.querySelectorAll('article').forEach(a => {
        //   const id = getId(a);
        //   if (!id) return;
        //   console.log(" --- a=", id, a)
        //   setIdState(id, a.classList.contains('text-collapsed') ? 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 && e.target.closest && e.target.closest(INSIDE_BOTTOM_BAR))) return;

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

    // // #Bug Update state for all articles on CLICK
    // document.querySelectorAll('article').forEach(a => {
    //   const id = getId(a);
    //   if (!id) return;
    //   console.log(" --- a=", id, a)
    //   setIdState(id, a.classList.contains('text-collapsed') ? STATE.COLLAPSED : STATE.EXPANDED);
    // });

    const id = getId(article);
    if (!id) return;
    setIdState(id, article.classList.contains('text-collapsed') ? STATE.COLLAPSED : STATE.EXPANDED);
  });

}


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

          // // Check for #prompt-textarea
          // if (node.matches(PROMPT_TEXTAREA_SELECTOR)) {
          //   doTextarea(node);
          // }

        }
      });
    }
  });
});

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