Fold/unfold user and assistant messages on Claude.ai
// ==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);
})();