ChatGPT Fold

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

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Advertisement:

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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