Collapse ChatGPT Code Blocks (robust)

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

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

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

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

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.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

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