ChatGPT Fold

Fold/unfold messages on chatgpt.com. New messages open by default; existing history folds.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

Advertisement:

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

Advertisement:

// ==UserScript==
// @name         ChatGPT Fold
// @namespace    local.tm.chatgpt.fold
// @version      2.0.0
// @description  Fold/unfold messages on chatgpt.com. New messages open by default; existing history folds.
//
// Architecture (v2 rewrite — see git history for the v1.x approach):
//   The v1 script injected its button/preview into React-owned nodes (the
//   content's parent). React replaces those nodes on every streamed token, so
//   the UI kept getting wiped and v1 fought back with timer ladders, global
//   click/keydown heuristics and deep fallback selectors — expensive, and the
//   newly arriving message still had no working toggle until a rescan landed.
//   v2 instead:
//     - anchors the button/preview on the [data-message-author-role] element,
//       which is stable for the lifetime of a message (same data-message-id);
//       only the .markdown inside it churns while streaming,
//     - folds via a data attribute on that element + CSS descendant rules, so
//       a React re-render of the content can't undo a fold,
//     - keys fold state by data-message-id (stable across remounts),
//     - runs ONE debounced MutationObserver scan; the scan is idempotent and
//       re-attaches anything React did manage to remove.
//
// DOM contract (verified live 2026-07-07, chatgpt.com) — everything the script
// relies on is in the SELECTORS block below; update there if the DOM changes:
//   - Turn wrapper: <section data-testid="conversation-turn-N" data-turn="user|assistant">
//     (tag not relied on; we match [data-turn] anywhere).
//   - Message element: [data-message-author-role="user|assistant"] with a
//     stable [data-message-id]. Real assistant answers also carry
//     [data-message-model-slug]; the transient reasoning "Thinking" pseudo
//     message does NOT — that's how we skip it.
//   - Assistant content: .markdown inside the message element.
//   - User content: [data-testid="collapsible-user-message-root"] inside the
//     .user-message-bubble-color bubble (bubble stays visible when folded,
//     preview text sits inside it).
//
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @grant        GM_addStyle
// ==/UserScript==

(() => {
  'use strict';

  // ── SELECTORS: the whole DOM contract lives here ───────────────────────────
  const SEL = {
    // A message element; the single anchor everything hangs off.
    msg: '[data-message-author-role="user"],[data-message-author-role="assistant"]',
    // Content to hide when folded, per role, in preference order.
    assistantContent: ['.markdown', '[class*="markdown"]', '.prose'],
    userContent: ['[data-testid="collapsible-user-message-root"]', '.whitespace-pre-wrap', '[class*="user-message"]'],
    // What the folded CSS actually hides for user turns. Deliberately narrower
    // than userContent: it must NOT match the .user-message-bubble-color
    // bubble itself — the bubble stays visible and holds the preview text.
    userHide: ['[data-testid="collapsible-user-message-root"]', '.whitespace-pre-wrap'],
    // An edit box appearing inside a turn means the user is editing it.
    editor: 'textarea,[contenteditable="true"]',
  };

  const ATTR = 'data-gfold';            // "" (decorated, open) | "folded" — drives the CSS
  const STORE_PREFIX = 'gfold.v2:';     // sessionStorage, one entry per conversation
  const MAX_OVERRIDES = 400;

  const ICON = {
    right: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6 3.5 10.5 8 6 12.5"/></svg>',
    down: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 6 8 10.5 12.5 6"/></svg>',
    allRight: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5 2 8 5 11 2M5 14 8 11 11 14"/></svg>',
    allDown: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5 5 8 2 11 5M5 11 8 14 11 11"/></svg>',
    refresh: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13 5A5.5 5.5 0 0 0 3.8 3.2L2.5 4.5"/><path d="M2.5 1.5v3h3"/><path d="M3 11A5.5 5.5 0 0 0 12.2 12.8l1.3-1.3"/><path d="M10.5 11.5h3v3"/></svg>'
  };

  const norm = s => (s || '').replace(/\s+/g, ' ').trim();

  // ── Per-conversation fold store (sessionStorage, cached in memory) ─────────
  // Shape: { defaultFolded: bool, items: {msgId: 0|1}, order: [msgId] }.
  // Read once per conversation, write-through on change — v1 re-parsed the
  // JSON on every single state query.
  let store = null;
  let storeKey = '';

  function conversationKey() {
    return STORE_PREFIX + location.pathname.replace(/\/$/, '');
  }

  function getStore() {
    const key = conversationKey();
    if (store && storeKey === key) return store;
    storeKey = key;
    try {
      const raw = JSON.parse(sessionStorage.getItem(key) || '{}');
      store = {
        defaultFolded: raw.defaultFolded !== false,
        items: (raw.items && typeof raw.items === 'object') ? raw.items : {},
        order: Array.isArray(raw.order) ? raw.order : []
      };
    } catch {
      store = { defaultFolded: true, items: {}, order: [] };
    }
    return store;
  }

  function persistStore() {
    try {
      while (store.order.length > MAX_OVERRIDES) delete store.items[store.order.shift()];
      sessionStorage.setItem(storeKey, JSON.stringify(store));
    } catch { /* folding still works in-memory */ }
  }

  function saveFolded(id, folded) {
    const s = getStore();
    s.order = s.order.filter(k => k !== id);
    if (folded === s.defaultFolded) {
      delete s.items[id];
    } else {
      s.items[id] = folded ? 1 : 0;
      s.order.push(id);
    }
    persistStore();
  }

  function savedFolded(id) {
    const s = getStore();
    return Object.prototype.hasOwnProperty.call(s.items, id) ? s.items[id] === 1 : s.defaultFolded;
  }

  // ── New-message tracking ───────────────────────────────────────────────────
  // Every message id present during the first scan of a conversation is
  // "existing" and gets the conversation default (folded). An id first seen
  // later, while it is the last content-bearing message of the thread, is a
  // newly arrived message: it opens by default and that choice is saved so it
  // survives remounts. The last-message guard keeps an old message scrolled
  // back into a virtualized thread from counting as new.
  const seenIds = new Set();
  let firstScanDone = false;

  function resetConversation() {
    seenIds.clear();
    firstScanDone = false;
    store = null; // re-read sessionStorage for the new path
  }

  // ── DOM helpers ────────────────────────────────────────────────────────────
  function messageEls() {
    return [...document.querySelectorAll(SEL.msg)].filter(el =>
      // Skip the transient reasoning pseudo-message ("Thinking" / "Thought for
      // Ns"): assistant-role element without a model slug.
      !(el.getAttribute('data-message-author-role') === 'assistant' &&
        !el.hasAttribute('data-message-model-slug'))
    );
  }

  function roleOf(el) { return el.getAttribute('data-message-author-role'); }

  function findContent(el) {
    const selectors = roleOf(el) === 'user' ? SEL.userContent : SEL.assistantContent;
    for (const sel of selectors) {
      for (const c of el.querySelectorAll(sel)) {
        if (c.closest('[data-gfold-ui]')) continue;
        if (norm(c.textContent) || c.querySelector('img,video,canvas')) return c;
      }
    }
    return null;
  }

  function previewText(el) {
    const text = norm((findContent(el) || el).textContent);
    return text.length > 160 ? text.slice(0, 160) + '…' : (text || '…');
  }

  // ── Fold mechanics ─────────────────────────────────────────────────────────
  // Folding = setting data-gfold="folded" on the message element. CSS hides
  // the content and shows the preview; React re-rendering the content
  // subtree cannot undo that. ui = {btn, preview} per message element.
  const uiFor = new WeakMap();

  function setFolded(el, folded, save = true) {
    el.setAttribute(ATTR, folded ? 'folded' : '');
    const ui = uiFor.get(el);
    if (ui) {
      if (folded) {
        const t = previewText(el);
        if (ui.preview.textContent !== t) ui.preview.textContent = t;
      }
      ui.btn.innerHTML = folded ? ICON.right : ICON.down;
      ui.btn.title = folded ? 'Expand message' : 'Fold message';
    }
    const id = el.getAttribute('data-message-id');
    if (save && id) saveFolded(id, folded);
  }

  function isFolded(el) { return el.getAttribute(ATTR) === 'folded'; }

  // Decorate one message element (idempotent; re-attaches UI React removed).
  function decorate(el) {
    const id = el.getAttribute('data-message-id');
    if (!id) return;

    let ui = uiFor.get(el);
    if (!ui || !ui.wrap.isConnected) {
      if (!findContent(el)) return; // nothing to fold yet (e.g. still thinking)

      const wrap = document.createElement('div');
      wrap.dataset.gfoldUi = '1';
      wrap.className = 'gfold-stickywrap';

      const btn = document.createElement('button');
      btn.type = 'button';
      btn.className = 'gfold-btn user-message-bubble-color';
      btn.innerHTML = ICON.down;
      btn.title = 'Fold message';
      btn.addEventListener('click', e => {
        e.preventDefault();
        e.stopPropagation();
        setFolded(el, !isFolded(el));
      });
      wrap.appendChild(btn);

      const preview = document.createElement('div');
      preview.dataset.gfoldUi = '1';
      preview.className = 'gfold-preview';
      preview.addEventListener('click', () => setFolded(el, false));

      placePreview(el, preview); // CSS shows it only while folded
      el.prepend(wrap);
      ui = { wrap, btn, preview };
      uiFor.set(el, ui);

      // First time we see this message: pick its initial state.
      if (!seenIds.has(id)) {
        seenIds.add(id);
        const s = getStore();
        if (Object.prototype.hasOwnProperty.call(s.items, id)) {
          setFolded(el, s.items[id] === 1, false);
        } else if (firstScanDone && isLastMessage(el)) {
          setFolded(el, false); // newly arrived → open, remembered
        } else {
          setFolded(el, s.defaultFolded, false);
        }
        return;
      }
      setFolded(el, savedFolded(id), false);
      return;
    }

    // Already decorated: re-attach the preview if React removed it, and keep
    // its text in sync while a folded message streams.
    if (!ui.preview.isConnected) placePreview(el, ui.preview);
    if (isFolded(el)) {
      const t = previewText(el);
      if (ui.preview.textContent !== t) ui.preview.textContent = t;
    }
  }

  // The user preview sits INSIDE the grey bubble (as the hidden content's
  // sibling) so the folded message keeps the bubble look in its original
  // spot; the assistant preview replaces the content column, so the top of
  // the message element is fine.
  function placePreview(el, preview) {
    if (roleOf(el) === 'user') {
      const content = findContent(el);
      if (content?.parentNode) {
        content.parentNode.insertBefore(preview, content);
        return;
      }
    }
    el.prepend(preview);
  }

  function isLastMessage(el) {
    const els = messageEls();
    for (let i = els.length - 1; i >= 0; i--) {
      if (els[i] === el) return true;
      if (findContent(els[i])) return false; // a later message with content exists
    }
    return false;
  }

  // ── Scan: the single entry point, run debounced off the MutationObserver ──
  function scan() {
    if (storeKey !== conversationKey()) resetConversation();
    const els = messageEls();
    for (const el of els) decorate(el);
    if (!firstScanDone && els.length) {
      for (const el of els) {
        const id = el.getAttribute('data-message-id');
        if (id) seenIds.add(id);
      }
      firstScanDone = true;
    }
  }

  // ── Fold all / unfold all / refresh controls ──────────────────────────────
  function setAll(folded) {
    const s = getStore();
    s.defaultFolded = folded;
    s.items = {};
    s.order = [];
    persistStore();
    for (const el of messageEls()) {
      if (uiFor.get(el)?.wrap.isConnected) setFolded(el, folded, false);
    }
  }

  function ensureControls() {
    if (document.getElementById('gfold-controls')) return;
    const box = document.createElement('div');
    box.id = 'gfold-controls';
    box.className = 'user-message-bubble-color';
    box.innerHTML = `
      <button type="button" data-act="expand" title="Expand all" aria-label="Expand all">${ICON.allDown}</button>
      <span class="gfold-divider" aria-hidden="true"></span>
      <button type="button" data-act="refresh" title="Refresh fold buttons" aria-label="Refresh fold buttons">${ICON.refresh}</button>
      <span class="gfold-divider" aria-hidden="true"></span>
      <button type="button" data-act="collapse" title="Collapse all" aria-label="Collapse all">${ICON.allRight}</button>
    `;
    box.addEventListener('click', e => {
      const act = e.target.closest('button[data-act]')?.dataset.act;
      if (act === 'refresh') scan();
      else if (act) setAll(act === 'collapse');
    });
    document.body.appendChild(box);
  }

  // ── Sticky offset: keep the rail buttons below the translucent header ─────
  function updateStickyTop() {
    const header = document.getElementById('page-header') ||
      document.querySelector('header,[role="banner"]');
    const h = header ? header.getBoundingClientRect().height : 64;
    document.documentElement.style.setProperty('--gfold-sticky-top', `${Math.ceil(h + 10)}px`);
  }

  // ── Styles ─────────────────────────────────────────────────────────────────
  GM_addStyle(`
    [${ATTR}]{position:relative}

    /* Folding: hide the content, show the preview — pure CSS from the message
       element's attribute, immune to React re-rendering the content nodes.
       A turn being edited (an editor appeared inside it) is never folded. */
    [${ATTR}="folded"]:not(:has(${SEL.editor})) :is(${SEL.assistantContent.join(',')}):not([data-gfold-ui] *){display:none}
    [${ATTR}="folded"][data-message-author-role="user"]:not(:has(${SEL.editor})) :is(${SEL.userHide.join(',')}):not([data-gfold-ui] *){display:none}
    .gfold-preview{display:none}
    [${ATTR}="folded"]:not(:has(${SEL.editor})) .gfold-preview{
      display:block;
      margin:8px 0;
      padding:8px 10px;
      border-radius:22px;
      background:rgba(127,127,127,.08);
      white-space:nowrap;
      overflow:hidden;
      text-overflow:ellipsis;
      cursor:pointer;
      max-width:100%;
      min-width:0;
      width:100%;
      box-sizing:border-box;
      align-self:stretch;
    }
    /* User preview renders inside the (still visible) grey bubble: no chrome. */
    [${ATTR}="folded"][data-message-author-role="user"] .gfold-preview{
      margin:0;padding:0;border-radius:0;background:transparent;
      width:auto;align-self:auto;
    }
    [${ATTR}]:has(${SEL.editor}) .gfold-stickywrap{display:none}

    /* Rail button, sticky while scrolling a tall message. */
    .gfold-stickywrap{
      position:sticky;
      top:var(--gfold-sticky-top,72px);
      height:0;
      overflow:visible;
      z-index:1;
      pointer-events:none;
      /* The message element is a flex column (user turns align items to the
         end); stretch so left:0 in here means the column's left edge. */
      width:100%;
      align-self:stretch;
    }
    .gfold-btn{
      position:absolute;
      width:32px;height:32px;
      border:0;border-radius:22px;
      display:flex;align-items:center;justify-content:center;
      padding:0;color:inherit;cursor:pointer;user-select:none;
      pointer-events:auto;
      box-shadow:0 1px 2px rgba(0,0,0,.10);
    }
    /* Both roles share one left rail (buttons vertically aligned). */
    [data-message-author-role="assistant"] .gfold-btn{left:-50px;top:12px}
    [data-message-author-role="user"] .gfold-btn{left:-50px;top:-2px}
    .gfold-btn:hover{filter:brightness(.98)}
    .gfold-btn:active{filter:brightness(.95)}
    .gfold-btn svg,#gfold-controls svg{
      width:17px;height:17px;display:block;
      fill:none;stroke:currentColor;stroke-width:2.1;
      stroke-linecap:round;stroke-linejoin:round;
    }

    /* Bottom-right control pill. */
    #gfold-controls{
      position:fixed;
      right:12px;
      bottom:calc(12px + env(safe-area-inset-bottom,0px));
      z-index:100000;
      display:flex;align-items:center;
      border-radius:22px;overflow:hidden;
      box-shadow:0 1px 2px rgba(0,0,0,.10);
    }
    #gfold-controls button{
      width:36px;height:36px;border:0;background:transparent;color:inherit;
      display:flex;align-items:center;justify-content:center;
      cursor:pointer;user-select:none;padding:0;
    }
    #gfold-controls button:hover{background:rgba(0,0,0,.05)}
    #gfold-controls button:active{background:rgba(0,0,0,.08)}
    #gfold-controls .gfold-divider{width:1px;height:18px;background:rgba(127,127,127,.35)}
  `);

  // ── Wiring: one debounced observer + SPA navigation hook ──────────────────
  let pending = 0;
  function schedule() {
    clearTimeout(pending);
    pending = setTimeout(() => { ensureControls(); scan(); }, 200);
  }

  // scan() itself notices a changed pathname (via the store key) and resets —
  // important because ChatGPT also calls replaceState mid-conversation (e.g.
  // a new chat acquiring its /c/<id> URL), which must NOT refold anything.
  function onNavigate() {
    updateStickyTop();
    schedule();
  }

  for (const fn of ['pushState', 'replaceState']) {
    const orig = history[fn];
    history[fn] = function (...args) {
      const ret = orig.apply(this, args);
      onNavigate();
      return ret;
    };
  }
  window.addEventListener('popstate', onNavigate);
  window.addEventListener('resize', updateStickyTop, { passive: true });

  new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true });
  ensureControls();
  updateStickyTop();
  scan();
})();