Collapse ChatGPT Code Blocks (robust)

Collapse/expand user & assistant code blocks on chat.openai.com/chatgpt.com

// ==UserScript==
// @name         Collapse ChatGPT Code Blocks (robust)
// @namespace    https://github.com/you/collapse-chatgpt-code
// @version      1.2.1
// @description  Collapse/expand user & assistant code blocks on chat.openai.com/chatgpt.com
// @author       Chef D
// @match        https://chat.openai.com/*
// @match        https://chatgpt.com/*
// @run-at       document-end
// @grant        none
// @noframes
// @license MIT
// ==/UserScript==

(() => {
    'use strict';
    let __cgptId = 0;
    const nextId = () => String(++__cgptId);

    // small badge to confirm load
    (function addLoadedBadge(){
        const id = 'cgpt-collapse-loaded-badge';
        if (document.getElementById(id)) return;
        const b = document.createElement('div');
        b.id = id;
        b.textContent = 'collapse-code: active';
        b.style.cssText = [
            'position:fixed;','bottom:6px;','right:8px;',
            'padding:2px 6px;','font:11px/1 system-ui,sans-serif;',
            'background:rgba(0,0,0,.5);','color:#fff;','border-radius:4px;',
            'z-index:999999;','pointer-events:none;','opacity:.35;'
        ].join('');
        document.body.appendChild(b);
        setTimeout(()=> b.remove(), 2000);
    })();

    // icons
    const TOGGLE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" style="transition:transform 120ms ease"><path d="M8.12 9.29L12 13.17l3.88-3.88a1 1 0 011.41 1.41l-4.59 4.59a1 1 0 01-1.41 0L6.71 10.7a1 1 0 111.41-1.41z" fill="currentColor"/></svg>`;
    const COPY_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"><path d="M16 1H4a2 2 0 00-2 2v12h2V3h12V1zm3 4H8a2 2 0 00-2 2v14a2 2 0 002 2h11a2 2 0 002-2V7a2 2 0 00-2-2zm0 16H8V7h11v14z" fill="currentColor"/></svg>`;

    // utils
    const debounce = (fn, wait=150) => { let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a), wait); }; };

    function createBtn({ label='', onClick, title='', style='' }) {
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.title = title || label;
        btn.textContent = label;
        btn.style.cssText = [
            'font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif;',
            'padding:4px 8px;','border-radius:6px;',
            'border:1px solid rgba(0,0,0,.2);',
            'background:rgba(240,240,240,.9);','color:#111;',
            'cursor:pointer;','user-select:none;',
            'display:inline-flex;','align-items:center;','gap:6px;',
            'transition:background 120ms ease,transform 60ms ease;',
            style
        ].join('');
        btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(230,230,230,1)'; });
        btn.addEventListener('mouseleave', () => { btn.style.background = 'rgba(240,240,240,.9)'; });
        btn.addEventListener('mousedown', () => { btn.style.transform = 'translateY(1px)'; });
        btn.addEventListener('mouseup', () => { btn.style.transform = 'translateY(0)'; });
        if (onClick) btn.addEventListener('click', onClick);
        return btn;
    }

    function createIconLabelBtn({ iconSVG, label, onClick, title }) {
        const btn = createBtn({ label:'', onClick, title });
        const span = document.createElement('span');
        span.textContent = label;
        btn.insertAdjacentHTML('afterbegin', iconSVG);
        btn.appendChild(span);
        return btn;
    }

    // DOM helpers
    function findMessageBubbleFrom(pre) {
        let bubble = pre.closest('[data-message-author-role]');
        if (bubble) return bubble;
        bubble = pre.closest('article,[data-message-id],[data-testid="conversation-turn"]');
        return bubble || pre.parentElement;
    }
    const getRole = (bubble) => bubble?.getAttribute?.('data-message-author-role') || null;

    // cleanup (shared legacy cleanup only)
    function cleanupPreCommon(preEl) {
        try {
            const sib = preEl.previousElementSibling;
            if (sib && (sib.tagName==='DIV'||sib.tagName==='SECTION') && sib.childElementCount<=6 &&
                /Copy|Show code|Hide code/i.test(sib.textContent||'')) sib.remove();
            preEl.querySelectorAll('div').forEach(div=>{
                if (div.dataset?.cgptControls==='bottom-left' || div.dataset?.cgptPreview==='1') return;
                const cs = getComputedStyle(div);
                if (cs.position==='absolute' && cs.top==='6px' && cs.right==='8px' &&
                    /Copy|Show|Hide/i.test(div.textContent||'')) div.remove();
            });
        } catch(_) {}
    }
    // extra cleanup only for user <pre> (remove any in-pre bottom-left bars from old versions)
    function cleanupUserPreExtras(preEl) {
        preEl.querySelectorAll('div[data-cgpt-controls="bottom-left"]').forEach(el => el.remove());
    }

    // controls placement
    function addBottomLeftControls(containerEl, ...controls) {
        containerEl.style.position = containerEl.style.position || 'relative';
        let ctr = containerEl.querySelector('div[data-cgpt-controls="bottom-left"]');
        if (!ctr) {
            ctr = document.createElement('div');
            ctr.dataset.cgptControls = 'bottom-left';
            ctr.style.cssText = [
                'position:absolute;','bottom:6px;','left:8px;',
                'display:flex;','gap:6px;','z-index:2;'
            ].join('');
            containerEl.appendChild(ctr);
        } else {
            ctr.innerHTML = '';
        }
        controls.forEach(c=> ctr.appendChild(c));
        return ctr;
    }
    function addBelowBubbleControls(bubble, ...controls) {
        bubble.style.position = bubble.style.position || 'relative';
        const rows = bubble.querySelectorAll('div[data-cgpt-controls="user-pre"]').length;
        const ctr = document.createElement('div');
        ctr.dataset.cgptControls = 'user-pre';
        ctr.style.cssText = [
            'position:absolute;','left:8px;',`bottom:${-32 - (rows*32)}px;`,
            'display:flex;','gap:6px;','z-index:2;'
        ].join('');
        controls.forEach(c=> ctr.appendChild(c));
        bubble.appendChild(ctr);
        const newRows = rows + 1;
        const needed = 16 + newRows*32;
        const cur = parseInt(getComputedStyle(bubble).marginBottom||'0',10)||0;
        if (cur < needed) bubble.style.marginBottom = `${needed}px`;
        return ctr;
    }

    function createToggle(setHidden, getHidden, texts={show:'Show', hide:'Hide'}) {
        const btn = createIconLabelBtn({
            iconSVG: TOGGLE_SVG,
            label: getHidden() ? texts.show : texts.hide,
            title: 'Toggle',
            onClick: (e)=>{
                e.stopPropagation();
                const h = !getHidden();
                setHidden(h);
                btn.querySelector('span').textContent = h ? texts.show : texts.hide;
                const svg = btn.querySelector('svg');
                if (svg) svg.style.transform = h ? 'rotate(-90deg)' : 'rotate(0deg)';
            }
        });
        const svg = btn.querySelector('svg');
        if (svg) svg.style.transform = getHidden() ? 'rotate(-90deg)' : 'rotate(0deg)';
        return btn;
    }

    function buildPreviewEl(codeEl) {
        const preview = document.createElement('div');
        preview.dataset.cgptPreview = '1';
        preview.style.cssText = [
            'font:12px/1.35 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;',
            'background:rgba(240,240,240,.6);','color:#444;',
            'border:1px dashed rgba(0,0,0,.25);','border-radius:6px;',
            'padding:6px 8px;','white-space:pre-wrap;','overflow:hidden;',
            'text-overflow:ellipsis;','max-height:7.2em;','margin:6px 0 0 0;'
        ].join('');
        const raw = (codeEl.innerText || codeEl.textContent || '').trim();
        const lines = raw.split('\n').slice(0, 5);
        let text = lines.join('\n');
        if (raw.length > text.length) text += '\n…';
        preview.textContent = text || '(empty)';
        return preview;
    }

    function buildMessagePreviewElFrom(node) {
        // Extract a compact snippet from the message body
        const getText = (n) =>
        (n.innerText || n.textContent || '')
        .replace(/\s+/g, ' ')
        .trim();

        let text = getText(node);
        if (text.length > 280) text = text.slice(0, 240) + '…';

        const preview = document.createElement('div');
        preview.dataset.cgptMsgPreview = '1';
        preview.textContent = text || '(empty)';
        preview.style.cssText = [
            'font:12px/1.35 system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif;',
            'background:rgba(240,240,240,.6);','color:#444;',
            'border:1px dashed rgba(0,0,0,.25);','border-radius:6px;',
            'padding:6px 8px;','white-space:normal;','overflow:hidden;',
            'text-overflow:ellipsis;','max-height:7.2em;','margin:6px 0 0 0;'
        ].join('');
        return preview;
    }

    // processors
    function processAssistantPre(pre) {
        const code = pre.querySelector('code'); if (!code) return;

        // skip cleanup after first time, otherwise we remove our own controls
        if (pre.dataset.__cgptProcessed==='assistant-pre') return;

        // first-time: clean legacy stuff, then add controls
        cleanupPreCommon(pre);

        let hidden = true;
        code.style.display = 'none';
        const toggleBtn = createToggle(
            (h)=>{ hidden=h; code.style.display = h?'none':''; },
            ()=> hidden,
            { show:'Show code', hide:'Hide code' }
        );
        addBottomLeftControls(pre, toggleBtn);
        pre.dataset.__cgptProcessed = 'assistant-pre';
    }

    function processUserPre(pre) {
        const code = pre.querySelector('code'); if (!code) return;

        // strong de-dupe: one control row per <pre>
        if (!pre.dataset.cgptId) pre.dataset.cgptId = nextId();
        const preId = pre.dataset.cgptId;
        const bubble = findMessageBubbleFrom(pre);
        if (bubble.querySelector(`div[data-cgpt-controls="user-pre"][data-for-pre="${preId}"]`)) return;

        // clean user legacy in-pre controls/previews (safe to do each scan)
        cleanupPreCommon(pre);
        cleanupUserPreExtras(pre);
        pre.querySelectorAll('div[data-cgpt-preview="1"]').forEach((el, i) => { if (i > 0) el.remove(); });

        // preview (build once)
        let preview = pre.querySelector('div[data-cgpt-preview="1"]');
        if (!preview) {
            preview = buildPreviewEl(code);
            pre.appendChild(preview);
        }

        // start collapsed with preview
        let hidden = true;
        code.style.display = 'none';
        preview.style.display = 'block';

        const copyBtn = createIconLabelBtn({
            iconSVG: COPY_SVG,
            label: 'Copy',
            title: 'Copy code',
            onClick: (e) => {
                e.stopPropagation();
                const text = code.innerText || code.textContent || '';
                navigator.clipboard.writeText(text).then(() => {
                    const lbl = copyBtn.querySelector('span');
                    const old = lbl.textContent;
                    lbl.textContent = 'Copied';
                    setTimeout(() => { lbl.textContent = old; }, 900);
                }).catch(() => {});
            }
        });

        const toggleBtn = createToggle(
            (h) => { hidden = h; code.style.display = h ? 'none' : ''; preview.style.display = h ? 'block' : 'none'; },
            () => hidden,
            { show: 'Show', hide: 'Hide' }
        );

        const ctr = addBelowBubbleControls(bubble, copyBtn, toggleBtn);
        ctr.setAttribute('data-for-pre', preId);
        pre.dataset.__cgptProcessed = 'user-pre';
    }

    function processUserMessageBubble(bubble) {
        if (!bubble || bubble.dataset.__cgptProcessed === 'user-bubble') return;

        // Find main message content
        let content =
            bubble.querySelector(':scope .markdown') ||
            bubble.querySelector(':scope [data-testid="markdown"]') ||
            bubble.querySelector(':scope [data-message-content]') ||
            bubble.querySelector(':scope [data-prose]');

        if (!content) {
            const wrap = document.createElement('div');
            wrap.dataset.cgptUserContent = '1';
            while (bubble.firstChild) { wrap.appendChild(bubble.firstChild); }
            bubble.appendChild(wrap);
            content = wrap;
        }

        // Ensure a single preview node exists
        let preview = bubble.querySelector('div[data-cgpt-msg-preview="1"]');
        if (!preview) {
            preview = buildMessagePreviewElFrom(content);
            preview.style.display = 'none'; // start expanded, so hide preview initially
            bubble.appendChild(preview);
        }

        // Toggle: when collapsing, update + show preview; when expanding, hide it
        let hidden = false;
        const msgToggle = createToggle(
            (h) => {
                hidden = h;
                if (hidden) {
                    // refresh preview text in case user edited the message
                    const fresh = buildMessagePreviewElFrom(content);
                    preview.textContent = fresh.textContent;
                }
                content.style.display = hidden ? 'none' : '';
                preview.style.display = hidden ? 'block' : 'none';
            },
            () => hidden,
            { show: 'Show message', hide: 'Hide message' }
        );

        addBottomLeftControls(bubble, msgToggle);
        bubble.dataset.__cgptProcessed = 'user-bubble';
    }

    // scanner
    function scan() {
        const pres = document.querySelectorAll('pre');

        pres.forEach((pre) => {
            const bubble = findMessageBubbleFrom(pre);
            const role = getRole(bubble);
            if (role === 'assistant') {
                processAssistantPre(pre);
            } else if (role === 'user') {
                processUserPre(pre);
            } else {
                // unknown => treat as User to avoid injecting assistant controls in user bubbles
                processUserPre(pre);
            }
        });

        document.querySelectorAll('[data-message-author-role="user"]').forEach(processUserMessageBubble);
    }

    const start = ()=> { try { scan(); } catch(e){ console.error(e); } };
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', start, { once:true });
    } else { start(); }

    const mo = new MutationObserver(debounce(scan, 200));
    mo.observe(document.documentElement, { childList:true, subtree:true });
    window.addEventListener('resize', debounce(scan, 300));
})();