Claude Fold

Fold/unfold user and assistant messages on Claude.ai

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name         Claude Fold
// @namespace    local.claudefold
// @version      1.7.0
// @description  Fold/unfold user and assistant messages on Claude.ai
// @match        https://claude.ai/*
// @grant        GM_addStyle
// ==/UserScript==

(() => {
  'use strict';

  // Selectors discovered from DOM inspection (both turn types are full-width
  // rows sharing the same left edge, so the fold buttons form a left rail).
  // The turn wrappers' utility classes churn with Claude UI updates (mt-6 →
  // mt-[var(--msg-gap,…)], pb-3 → pb-[var(--msg-assistant-pb,…)]), so match on
  // the stable part and anchor with :has() on the content instead:
  // User turns:      div.mb-1.group  :has  [data-testid="user-message"]
  // Assistant turns: div.group.relative  >  .font-claude-response

  const USER_CONTENT = '[data-testid="user-message"]';
  const ASST_CONTENT = '.font-claude-response';
  const USER_TURN    = `div.mb-1.group:has(${USER_CONTENT})`;
  const ASST_TURN    = `div.group.relative:has(> ${ASST_CONTENT})`;

  const ATTR = 'data-cfold';

  // Registry of every per-message toggle so the "Fold all" button can drive them.
  // Entries carry their turn node so dead ones can be pruned (see prune()).
  const toggles = new Set();
  // {turn, btn} for each rail button, used to keep buttons in view while
  // scrolling through tall messages (see stickRailButtons).
  let railButtons = [];

  // Drop entries whose turn left the DOM (message deleted, SPA navigation).
  // Without this both registries grow forever and the scroll handler keeps
  // iterating dead nodes every frame.
  function prune() {
    if (railButtons.some(({ turn }) => !turn.isConnected)) {
      railButtons = railButtons.filter(({ turn }) => turn.isConnected);
    }
    toggles.forEach(t => { if (!t.turn.isConnected) toggles.delete(t); });
  }

  const ICONS = {
    // chevron-right — shown when a message is folded (click to unfold)
    fold:    '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6 3.5 10.5 8 6 12.5"/></svg>',
    // chevron-down — shown when a message is unfolded (click to fold)
    unfold:  '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 6 8 10.5 12.5 6"/></svg>',
    // double chevron — the fold-all / unfold-all control
    allfold: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5 2 8 5 11 2M5 14 8 11 11 14"/></svg>',
  };

  function snippet(el) {
    return (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 140);
  }

  // host    – where the toggle button is appended (the turn)
  // foldEl  – the element hidden/shown when folding
  // srcEl   – the element the preview text and font are taken from
  //           (defaults to foldEl)
  function makeToggle(host, foldEl, preview, btnClass, srcEl) {
    srcEl = srcEl || foldEl;
    const btn = document.createElement('button');
    btn.type = 'button';
    btn.className = 'cfold-btn ' + btnClass;
    btn.innerHTML = ICONS.unfold;
    btn.title = 'Fold';

    // Copy the font from the actual text element (a <p>), not the wrapper:
    // the assistant wrapper computes to 14px while its paragraphs render at
    // 16px, so copying the wrapper would shrink the folded preview.
    const textEl = srcEl.querySelector('p') || srcEl;
    const cfont = getComputedStyle(textEl);
    preview.style.fontFamily = cfont.fontFamily;
    preview.style.fontSize   = cfont.fontSize;
    preview.style.lineHeight = cfont.lineHeight;

    let folded = false;

    function setFolded(want) {
      if (want === folded) return;
      folded = want;
      foldEl.style.display  = folded ? 'none' : '';
      preview.textContent   = folded ? (snippet(srcEl) || '…') : '';
      preview.style.display = folded ? 'block' : 'none';
      btn.innerHTML         = folded ? ICONS.fold : ICONS.unfold;
      btn.title             = folded ? 'Expand' : 'Fold';
    }
    function toggle() { setFolded(!folded); }

    btn.addEventListener('click', function(e) { e.stopPropagation(); toggle(); refreshFoldAllLabel(); });
    preview.addEventListener('click', function () { toggle(); refreshFoldAllLabel(); });
    host.appendChild(btn);

    const entry = { setFolded, isFolded: () => folded, turn: host };
    toggles.add(entry);
    railButtons.push({ turn: host, btn });
  }

  function injectUser(turn) {
    if (turn.hasAttribute(ATTR)) return;
    const content = turn.querySelector(USER_CONTENT);
    if (!content) return;

    turn.setAttribute(ATTR, 'user');
    turn.style.position = 'relative';
    turn.style.paddingLeft = '1.75rem';   // gutter for the left-rail button

    // Hide the whole bubble and show a right-aligned preview in its place. The
    // preview is a sibling of the bubble (not inside it) so it is bounded by the
    // column instead of growing the narrow bubble past the right edge.
    const bubble = content.closest('[class*="rounded"]') || content;
    const preview = document.createElement('div');
    preview.className = 'cfold-preview cfold-preview-user';
    // Mirror the real bubble's look (bg/color/radius/padding) so the folded
    // preview matches the user bubble in any theme. Tailwind's --bg-300 holds
    // raw HSL parts, not a usable color, so we copy the computed values.
    const bs = getComputedStyle(bubble);
    preview.style.background   = bs.backgroundColor;
    preview.style.color        = bs.color;
    preview.style.borderRadius = bs.borderRadius;
    preview.style.paddingTop    = bs.paddingTop;
    preview.style.paddingRight  = bs.paddingRight;
    preview.style.paddingBottom = bs.paddingBottom;
    preview.style.paddingLeft   = bs.paddingLeft;
    // Insert where the bubble sits (before the message's controls row) so
    // folding doesn't push the message below the timestamp/edit/copy controls.
    bubble.parentNode.insertBefore(preview, bubble);

    // Button lives in the left gutter of the turn (a left rail), always visible.
    makeToggle(turn, bubble, preview, 'cfold-btn-user', content);
  }

  function injectAsst(turn) {
    if (turn.hasAttribute(ATTR)) return;
    const content = turn.querySelector(ASST_CONTENT);
    if (!content) return;

    turn.setAttribute(ATTR, 'asst');
    turn.style.position = 'relative';
    turn.style.paddingLeft = '1.75rem';   // gutter for the left-rail button

    const preview = document.createElement('div');
    preview.className = 'cfold-preview cfold-preview-asst';
    content.parentNode.insertBefore(preview, content);

    // Button lives in the left gutter of the turn (a left rail), always visible.
    makeToggle(turn, content, preview, 'cfold-btn-asst');
  }

  function scan() {
    document.querySelectorAll(USER_TURN).forEach(injectUser);
    document.querySelectorAll(ASST_TURN).forEach(injectAsst);
  }

  // ── Fold all / Unfold all ─────────────────────────────────
  let foldAllBtn;
  function refreshFoldAllLabel() {
    if (!foldAllBtn) return;
    const anyOpen = [...toggles].some(t => !t.isFolded());
    const label = (toggles.size && !anyOpen) ? 'Unfold all' : 'Fold all';
    // State is conveyed via the tooltip; the icon stays constant. (title is an
    // attribute, not childList, so it never re-triggers the MutationObserver.)
    if (foldAllBtn.title !== label) foldAllBtn.title = label;
  }
  function addFoldAllButton() {
    if (foldAllBtn) return;
    foldAllBtn = document.createElement('button');
    foldAllBtn.type = 'button';
    foldAllBtn.id = 'cfold-all';
    foldAllBtn.innerHTML = ICONS.allfold;
    foldAllBtn.title = 'Fold all';
    foldAllBtn.addEventListener('click', function () {
      // If anything is open, fold everything; otherwise unfold everything.
      const anyOpen = [...toggles].some(t => !t.isFolded());
      toggles.forEach(t => t.setFolded(anyOpen));
      refreshFoldAllLabel();
    });
    document.body.appendChild(foldAllBtn);
  }

  GM_addStyle(`
    /* ── Fold button (always-visible, in a left rail) ──────── */
    /* The chat scroll container is overflow-x:hidden, so a negative-left
       button would be clipped. Instead we indent each turn (inline style in
       injectUser/injectAsst) and drop the button into that indent. */
    .cfold-btn {
      position: absolute;
      top: 4px;
      left: 0;               /* sits in the turn's left indent */
      width: 20px;
      height: 20px;
      padding: 0;
      border: none;
      border-radius: 50%;
      background: transparent;
      color: var(--text-400, #9b9b94);
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      opacity: .55;          /* always visible, brightens on hover */
      transition: opacity .15s, background .1s, color .1s;
      z-index: 10;
    }
    .cfold-btn:hover,
    .cfold-btn:focus-visible {
      opacity: 1;
      color: #c96442;
      background: rgba(128,128,128,.15);
    }
    .cfold-btn svg {
      width: 12px;
      height: 12px;
      fill: none;
      stroke: currentColor;
      stroke-width: 2.4;
      stroke-linecap: round;
      stroke-linejoin: round;
      display: block;
    }

    /* ── Preview stub shown when folded ────────────────────── */
    /* Font-family/size/line-height are copied from the message at runtime
       so folding keeps the original typography. */
    .cfold-preview {
      display: none;
      padding: 2px 6px;
      max-width: 100%;          /* never wider than the column */
      box-sizing: border-box;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      cursor: pointer;
      border-radius: 6px;
    }
    /* User preview keeps the original grey-bubble look: right-aligned, pinned
       to the column's right edge so long text ellipsizes instead of
       overflowing. Styling mirrors the real user bubble (bg-300 / rounded-xl). */
    /* background / color / border-radius / padding are copied from the real
       user bubble at runtime (see injectUser) so it matches any theme. */
    .cfold-preview-user {
      width: max-content;
      margin-left: auto;
    }
    .cfold-preview-user:hover {
      filter: brightness(0.97);
    }
    .cfold-preview-asst {
      max-width: 640px;
    }
    .cfold-preview-asst:hover {
      background: rgba(128,128,128,.08);
    }

    /* ── Fold-all floating icon button (bottom-right corner) ─ */
    #cfold-all {
      position: fixed;
      right: 12px;
      bottom: 12px;
      z-index: 9999;
      width: 28px;
      height: 28px;
      display: flex;
      align-items: center;
      justify-content: center;
      border: none;
      border-radius: 50%;
      background: rgba(110,110,110,.5);
      color: #fff;
      cursor: pointer;
      box-shadow: 0 1px 4px rgba(0,0,0,.2);
      opacity: .75;
    }
    #cfold-all:hover { opacity: 1; background: rgba(110,110,110,.8); }
    #cfold-all svg {
      width: 15px;
      height: 15px;
      fill: none;
      stroke: currentColor;
      stroke-width: 1.8;
      stroke-linecap: round;
      stroke-linejoin: round;
      display: block;
    }
  `);

  // ── Keep each rail button in view while scrolling a tall message ──
  // CSS position:sticky can't be used: the per-turn wrappers use
  // content-visibility, whose containment disables sticky relative to the
  // outer scroller. So we reposition the (absolute) buttons on scroll instead,
  // clamped to stay within their own message.
  const TOP_OFFSET = 8, MIN_TOP = 4, BTN = 20;
  let scroller = null;
  function findScroller(node) {
    let el = node && node.parentElement;
    while (el) {
      const o = getComputedStyle(el);
      if (/(auto|scroll)/.test(o.overflowY) && el.scrollHeight - el.clientHeight > 20) return el;
      el = el.parentElement;
    }
    return null;
  }
  function stickRailButtons() {
    if (!railButtons.length) return;
    if (!scroller || !document.contains(scroller)) scroller = findScroller(railButtons[0].turn);
    // Reference the scroller's CONTENT-box top (include its top padding). That
    // padding exists to clear the translucent header / top fade overlay, so
    // resting buttons there keeps them from dimming under the fade.
    let refTop = 0, viewBottom = innerHeight;
    if (scroller) {
      const sr = scroller.getBoundingClientRect();
      const pad = parseFloat(getComputedStyle(scroller).paddingTop) || 0;
      refTop = sr.top + pad;
      viewBottom = sr.bottom;
    }
    for (const { turn, btn } of railButtons) {
      if (!document.contains(turn)) continue;
      const r = turn.getBoundingClientRect();
      if (r.bottom < refTop || r.top > viewBottom) continue;
      const max = Math.max(MIN_TOP, turn.clientHeight - BTN - MIN_TOP);
      const top = Math.min(Math.max(refTop + TOP_OFFSET - r.top, MIN_TOP), max);
      btn.style.top = top + 'px';
    }
  }
  let rafPending = false;
  function onScroll() {
    if (rafPending) return;
    rafPending = true;
    requestAnimationFrame(() => { rafPending = false; stickRailButtons(); });
  }

  function tick() {
    prune();
    scan();
    addFoldAllButton();
    refreshFoldAllLabel();
    stickRailButtons();
  }

  // Debounce: Claude mutates the DOM constantly (streaming, cursors, etc.).
  // Coalesce bursts so we re-scan at most ~every 200ms.
  let pending;
  function schedule() {
    clearTimeout(pending);
    pending = setTimeout(tick, 200);
  }

  tick();
  new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true });
  // Capture phase so scrolls from any inner scroll container are caught.
  document.addEventListener('scroll', onScroll, true);
  window.addEventListener('resize', onScroll);

})();