Fold/unfold messages on chatgpt.com. New messages open by default; existing history folds.
// ==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();
})();