ChatGPT Message Queue

Send message while ChatGPT is still thinking and automatically send when ChatGPT is ready. Inline queue UI with drag-to-reorder, edit/delete, merge queue items option and persistence. (the code is not great, but it works ig...). Also images don't work since that's a whole 'nother beast.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         ChatGPT Message Queue
// @namespace    https://github.com/maribox/my_userscripts
// @license MIT
// @version      1.0.0
// @description  Send message while ChatGPT is still thinking and automatically send when ChatGPT is ready. Inline queue UI with drag-to-reorder, edit/delete, merge queue items option and persistence. (the code is not great, but it works ig...). Also images don't work since that's a whole 'nother beast.
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(() => {
    'use strict';

    const storageKeyQueues = 'cgpt_message_queue_by_conversation_v1';
    const storageKeySettings = 'cgpt_message_queue_settings_by_conversation_v1';

    const pollIntervalMilliseconds = 1200;
    const transactionWaitMilliseconds = 0;
    const sendEnableWaitMilliseconds = 0;
    const streamingStartWaitMilliseconds = 0;

    const queueHostPaddingTopPixels = 10;

    const dragHandleWidthPixels = 44;

    const queueItemPaddingPixels = 12;
    const queueTextFontSizePixels = 15;
    const queueLineHeight = 1.4;

    const draftRestoreRetryDelays = [0, 40, 120, 260, 420, 700, 1100, 1600, 2300, 3200];

    const enterAnimationMilliseconds = 160;
    const exitSendAnimationMilliseconds = 170;
    const exitDeleteAnimationMilliseconds = 150;

    let currentConversationKey = null;

    let promptQueue = [];
    let transientQueue = [];

    let mergeMessagesEnabled = false;
    let transientMergeMessagesEnabled = false;

    let isQueuePumpRunning = false;
    let proseMirrorTransactionCounter = 0;

    let attachedComposerNode = null;
    let attachedSubmitButtonNode = null;
    let submitButtonMutationObserver = null;
    let queueHostNode = null;

    let activeEditIndex = null;
    let activeEditOriginalText = '';

    let isDragging = false;
    let dragSourceIndex = null;
    let dropInsertIndex = null;

    let hostListenersAttached = false;
    let dropLineNode = null;

    let sendCancellationToken = 0;

    let pendingDraftRestore = null;

    let lastRenderedQueueSnapshot = [];
    let pendingEnterCountFromBottom = 0;

    const sleep = (ms) => new Promise(r => setTimeout(r, ms));

    const waitFor = async (predicate, timeoutMilliseconds) => {
        if (predicate()) return true;
        await Promise.resolve();
        if (predicate()) return true;
        await new Promise(requestAnimationFrame);
        if (predicate()) return true;
        await new Promise(requestAnimationFrame);
        if (predicate()) return true;

        if (timeoutMilliseconds <= 0) return false;

        const start = Date.now();
        while (Date.now() - start < timeoutMilliseconds) {
            if (predicate()) return true;
            await new Promise(requestAnimationFrame);
        }
        return false;
    };

    const isInteracting = () => isDragging || activeEditIndex !== null;

    function normalizeText(text) {
        return String(text || '').replace(/\u200B/g, '').replace(/\r\n/g, '\n').trim();
    }

    function ensureAnimationStyles() {
        if (document.getElementById('tm-cgpt-message-queue-styles')) return;

        const style = document.createElement('style');
        style.id = 'tm-cgpt-message-queue-styles';
        style.textContent = `
      @keyframes tmqEnterFromBottom {
        from { opacity: 0; transform: translateY(10px); }
        to   { opacity: 1; transform: translateY(0); }
      }
      @keyframes tmqExitToRight {
        from { opacity: 1; transform: translateX(0); }
        to   { opacity: 0; transform: translateX(14px); }
      }
      @keyframes tmqExitToBottom {
        from { opacity: 1; transform: translateY(0); }
        to   { opacity: 0; transform: translateY(10px); }
      }
      .tmq-enter {
        animation: tmqEnterFromBottom ${enterAnimationMilliseconds}ms ease-out both;
        will-change: transform, opacity;
      }
      .tmq-exit-send {
        animation: tmqExitToRight ${exitSendAnimationMilliseconds}ms ease-in both;
        will-change: transform, opacity;
      }
      .tmq-exit-delete {
        animation: tmqExitToBottom ${exitDeleteAnimationMilliseconds}ms ease-in both;
        will-change: transform, opacity;
      }
    `;
        document.head.appendChild(style);
    }

    // ---------- Conversation key ----------
    function extractConversationIdFromUrl() {
        const path = location.pathname || '';
        const match = path.match(/\/c\/([a-z0-9-]{8,})/i) || path.match(/\/chat\/([a-z0-9-]{8,})/i);
        return match ? match[1] : null;
    }

    function computeConversationKey() {
        const conversationId = extractConversationIdFromUrl();
        return conversationId ? `conversation:${conversationId}` : null;
    }

    // ---------- Storage ----------
    function loadJsonObject(key) {
        try {
            const parsed = JSON.parse(localStorage.getItem(key) || '{}');
            return parsed && typeof parsed === 'object' ? parsed : {};
        } catch {
            return {};
        }
    }

    function loadQueueForConversation(conversationKey) {
        if (!conversationKey) return [];
        const loaded = loadJsonObject(storageKeyQueues)[conversationKey];
        return Array.isArray(loaded) ? loaded.map(String).filter(s => s.trim()) : [];
    }

    function saveQueueForConversation(conversationKey, queueArray) {
        if (!conversationKey) return;
        const all = loadJsonObject(storageKeyQueues);
        if (Array.isArray(queueArray) && queueArray.length) all[conversationKey] = queueArray;
        else delete all[conversationKey];
        localStorage.setItem(storageKeyQueues, JSON.stringify(all));
    }

    function loadMergeSettingForConversation(conversationKey) {
        if (!conversationKey) return false;
        const all = loadJsonObject(storageKeySettings);
        return all[conversationKey]?.mergeMessagesEnabled === true;
    }

    function saveMergeSettingForConversation(conversationKey, value) {
        if (!conversationKey) return;
        const all = loadJsonObject(storageKeySettings);
        all[conversationKey] = { ...(all[conversationKey] || {}), mergeMessagesEnabled: value === true };
        localStorage.setItem(storageKeySettings, JSON.stringify(all));
    }

    function persistCurrentStateIfPossible() {
        if (!currentConversationKey) return;
        saveQueueForConversation(currentConversationKey, promptQueue);
        saveMergeSettingForConversation(currentConversationKey, mergeMessagesEnabled);
    }

    function switchConversationIfNeeded() {
        const nextConversationKey = computeConversationKey();
        if (nextConversationKey === currentConversationKey) return;

        if (currentConversationKey) persistCurrentStateIfPossible();

        if (!currentConversationKey && nextConversationKey && transientQueue.length) {
            promptQueue = transientQueue.slice();
            mergeMessagesEnabled = transientMergeMessagesEnabled;

            transientQueue = [];
            transientMergeMessagesEnabled = false;

            currentConversationKey = nextConversationKey;
            persistCurrentStateIfPossible();

            activeEditIndex = null;
            renderQueueHard();
            pumpQueue();
            return;
        }

        currentConversationKey = nextConversationKey;

        if (currentConversationKey) {
            promptQueue = loadQueueForConversation(currentConversationKey);
            mergeMessagesEnabled = loadMergeSettingForConversation(currentConversationKey);
        } else {
            transientQueue = [];
            promptQueue = transientQueue;
            transientMergeMessagesEnabled = false;
            mergeMessagesEnabled = false;
        }

        activeEditIndex = null;
        lastRenderedQueueSnapshot = [];
        pendingEnterCountFromBottom = 0;
        renderQueueHard();
        pumpQueue();
    }

    // ---------- DOM hooks ----------
    function composerNode() {
        return document.querySelector('div#prompt-textarea.ProseMirror[contenteditable="true"]')
            || document.querySelector('div#prompt-textarea[contenteditable="true"]');
    }

    function composerContainerNode() {
        const composer = composerNode();
        return composer?.closest('.wcDTda_prosemirror-parent') || composer?.parentElement || null;
    }

    function submitButtonNode() {
        return document.querySelector('button#composer-submit-button');
    }

    function submitButtonMode() {
        return submitButtonNode()?.getAttribute('data-testid') || null;
    }

    function isStreaming() {
        return submitButtonMode() === 'stop-button';
    }

    function isSendEnabled() {
        const button = submitButtonNode();
        if (!button || button.getAttribute('data-testid') !== 'send-button') return false;
        if (button.disabled) return false;
        if ((button.getAttribute('aria-disabled') || '').toLowerCase() === 'true') return false;
        if (getComputedStyle(button).pointerEvents === 'none') return false;
        return true;
    }

    function clickSubmitButtonHuman() {
        const button = submitButtonNode();
        if (!button) return false;
        button.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1 }));
        button.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
        button.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1 }));
        button.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
        button.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
        return true;
    }

    function currentComposerText() {
        return normalizeText(composerNode()?.innerText || '');
    }

    // ---------- Icons ----------
    function spriteFileBaseHref() {
        const anyUse = document.querySelector('use[href*="sprites-core"]');
        const href = anyUse?.getAttribute('href') || '';
        return href.includes('#') ? href.split('#')[0] : null;
    }

    function makeEditIconOrFallback() {
        const spriteBase = spriteFileBaseHref();
        if (!spriteBase) return document.createTextNode('✎');

        const span = document.createElement('span');
        span.className = 'flex items-center justify-center touch:w-10 h-8 w-8';
        span.contentEditable = 'false';

        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
        svg.setAttribute('width', '20');
        svg.setAttribute('height', '20');
        svg.setAttribute('aria-hidden', 'true');
        svg.setAttribute('class', 'icon');

        const use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
        use.setAttribute('href', `${spriteBase}#6d87e1`);
        use.setAttribute('fill', 'currentColor');

        svg.appendChild(use);
        span.appendChild(svg);
        return span;
    }

    function makeIconButton({ title, kind }) {
        const button = document.createElement('button');
        button.type = 'button';
        button.title = title;
        button.contentEditable = 'false';
        button.style.cssText =
            'width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;' +
            'border-radius:999px;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.06);' +
            'color:inherit;cursor:pointer;user-select:none;flex:0 0 auto;padding:0;';
        button.addEventListener('mouseenter', () => { button.style.background = 'rgba(255,255,255,.10)'; });
        button.addEventListener('mouseleave', () => { button.style.background = 'rgba(255,255,255,.06)'; });

        if (kind === 'edit') button.appendChild(makeEditIconOrFallback());
        if (kind === 'delete') button.appendChild(document.createTextNode('✕'));
        if (kind === 'save') button.appendChild(document.createTextNode('✓'));
        if (kind === 'cancel') button.appendChild(document.createTextNode('↩'));

        return button;
    }

    // ---------- Layout styles ----------
    function queueHostStyle() {
        return [
            'position:relative',
            'display:flex',
            'flex-direction:column',
            'gap:10px',
            `padding-top:${queueHostPaddingTopPixels}px`,
            'width:100%',
            'box-sizing:border-box',
        ].join(';');
    }

    // SINGLE CONTAINER ROW: handle + bubble live together and animate together
    function queueRowWrapperStyle() {
        return [
            'position:relative',
            'display:flex',
            'align-items:stretch',
            'gap:10px',
            'max-width:100%',
            'box-sizing:border-box',
        ].join(';');
    }

    function dragHandleStyle() {
        return [
            `width:${dragHandleWidthPixels}px`,
            'flex:0 0 auto',
            'display:flex',
            'align-items:center',
            'justify-content:center',
            'align-self:stretch', // IMPORTANT: same height as bubble
            'border-radius:12px',
            'border:1px solid rgba(255,255,255,.12)',
            'background:rgba(255,255,255,.06)',
            'cursor:grab',
            'user-select:none',
            'opacity:.95',
            'box-sizing:border-box',
        ].join(';');
    }

    function queueItemBoxStyle() {
        return [
            'display:flex',
            'gap:10px',
            'align-items:stretch',
            'flex:1 1 auto',
            `padding:${queueItemPaddingPixels}px`,
            'border-radius:14px',
            'border:1px solid rgba(255,255,255,.10)',
            'background:rgba(255,255,255,.06)',
            'color:inherit',
            `font-size:${queueTextFontSizePixels}px`,
            `line-height:${queueLineHeight}`,
            'max-width:100%',
            'box-sizing:border-box',
            'min-width:0',
        ].join(';');
    }

    // Make the text vertically centered
    function queueTextStyle() {
        return [
            'flex:1',
            'min-width:0',
            'white-space:pre-wrap',
            'overflow-wrap:anywhere',
            'word-break:break-word',
            'display:flex',
            'align-items:center',
            'box-sizing:border-box',
        ].join(';');
    }

    // ---------- Host ----------
    function ensureQueueHost() {
        const container = composerContainerNode();
        const composer = composerNode();
        if (!container || !composer) return null;

        if (queueHostNode && queueHostNode.parentElement !== container) {
            queueHostNode.remove();
            queueHostNode = null;
            hostListenersAttached = false;
        }

        if (!queueHostNode) {
            queueHostNode = document.createElement('div');
            queueHostNode.id = 'tm-cgpt-message-queue';
            queueHostNode.contentEditable = 'false';
            queueHostNode.style.cssText = queueHostStyle();
            container.insertBefore(queueHostNode, composer);
        } else {
            queueHostNode.style.cssText = queueHostStyle();
        }

        if (!hostListenersAttached) {
            hostListenersAttached = true;
            queueHostNode.addEventListener('dragover', onHostDragOver, true);
            queueHostNode.addEventListener('drop', onHostDrop, true);
            queueHostNode.addEventListener('dragleave', onHostDragLeave, true);
        }

        return queueHostNode;
    }

    // ---------- Drop line ----------
    function clearDropLine() {
        if (dropLineNode) dropLineNode.remove();
        dropLineNode = null;
        dropInsertIndex = null;
    }

    function showDropLineAt(host, topWithinHostPixels) {
        if (!host) return;

        if (!dropLineNode) {
            dropLineNode = document.createElement('div');
            dropLineNode.contentEditable = 'false';
            dropLineNode.style.cssText =
                'position:absolute;pointer-events:none;height:0;' +
                'left:0;right:0;' +
                'border-top:2px solid rgba(255,255,255,.25);';
            host.appendChild(dropLineNode);
        }
        dropLineNode.style.top = `${Math.max(0, topWithinHostPixels)}px`;
    }

    function listRowWrappers(host) {
        return Array.from(host.querySelectorAll('[data-queue-row="true"]'));
    }

    function onHostDragLeave(event) {
        if (!isDragging) return;
        const related = event.relatedTarget;
        if (related && queueHostNode && queueHostNode.contains(related)) return;
        clearDropLine();
    }

    function onHostDragOver(event) {
        if (!isDragging || activeEditIndex !== null) return;
        const host = ensureQueueHost();
        if (!host) return;

        event.preventDefault();
        event.dataTransfer.dropEffect = 'move';

        const wrappers = listRowWrappers(host);
        if (!wrappers.length) return;

        const hostRect = host.getBoundingClientRect();
        const mouseY = event.clientY;

        let insertIndex = wrappers.length;
        let lineY = null;

        for (let i = 0; i < wrappers.length; i++) {
            const rect = wrappers[i].getBoundingClientRect();
            const mid = rect.top + rect.height / 2;

            if (mouseY < mid) {
                insertIndex = i;
                lineY = rect.top - hostRect.top;
                break;
            }
            lineY = rect.bottom - hostRect.top;
        }

        dropInsertIndex = insertIndex;
        showDropLineAt(host, lineY ?? 0);
    }

    function onHostDrop(event) {
        if (!isDragging || activeEditIndex !== null) return;

        event.preventDefault();
        event.stopPropagation();

        let sourceIndex = dragSourceIndex;
        if (sourceIndex === null) {
            const dt = (() => { try { return event.dataTransfer.getData('text/plain'); } catch { return ''; } })();
            sourceIndex = dt ? Number(dt) : null;
        }

        if (sourceIndex === null || Number.isNaN(sourceIndex)) {
            isDragging = false;
            dragSourceIndex = null;
            clearDropLine();
            return;
        }

        const rawInsertIndex = (dropInsertIndex === null) ? promptQueue.length : dropInsertIndex;
        const boundedInsertIndex = Math.max(0, Math.min(rawInsertIndex, promptQueue.length));

        const movedItem = promptQueue.splice(sourceIndex, 1)[0];

        let insertIndex = boundedInsertIndex;
        if (sourceIndex < insertIndex) insertIndex -= 1;

        insertIndex = Math.max(0, Math.min(insertIndex, promptQueue.length));
        promptQueue.splice(insertIndex, 0, movedItem);

        isDragging = false;
        dragSourceIndex = null;
        clearDropLine();

        persistCurrentStateIfPossible();
        renderQueueHard();
        pumpQueue();
    }

    // ---------- Drag handle ----------
    function makeDragHandle(index) {
        const handle = document.createElement('div');
        handle.contentEditable = 'false';
        handle.setAttribute('role', 'button');
        handle.setAttribute('aria-label', 'Drag to reorder');
        handle.setAttribute('draggable', activeEditIndex === null ? 'true' : 'false');
        handle.style.cssText = dragHandleStyle();
        handle.textContent = '⠿';

        handle.addEventListener('mouseenter', () => { handle.style.background = 'rgba(255,255,255,.10)'; });
        handle.addEventListener('mouseleave', () => { handle.style.background = 'rgba(255,255,255,.06)'; });

        handle.addEventListener('dragstart', (event) => {
            if (activeEditIndex !== null) { event.preventDefault(); return; }

            sendCancellationToken++;
            isDragging = true;
            dragSourceIndex = index;
            clearDropLine();

            event.dataTransfer.effectAllowed = 'move';
            try { event.dataTransfer.setData('text/plain', String(index)); } catch { }

            handle.style.cursor = 'grabbing';
        }, true);

        handle.addEventListener('dragend', () => {
            handle.style.cursor = 'grab';
            isDragging = false;
            dragSourceIndex = null;
            clearDropLine();
            if (promptQueue.length && !isStreaming()) pumpQueue();
        }, true);

        return handle;
    }

    // ---------- Composer manipulation (paste-first) ----------
    function fireInputEvents(composer) {
        try { composer.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, inputType: 'insertText', data: '' })); } catch { }
        try { composer.dispatchEvent(new InputEvent('input', { bubbles: true })); } catch { }
    }

    function clearComposer() {
        const composer = composerNode();
        if (!composer) return;
        composer.focus();

        try {
            document.execCommand('selectAll', false, null);
            document.execCommand('delete', false, null);
        } catch {
            composer.textContent = '';
        }

        fireInputEvents(composer);
    }

    function pasteIntoComposer(text) {
        const composer = composerNode();
        if (!composer) return false;

        composer.focus();

        try {
            const dt = new DataTransfer();
            dt.setData('text/plain', text);

            const pasteEvent = new ClipboardEvent('paste', {
                bubbles: true,
                cancelable: true,
                clipboardData: dt,
            });

            composer.dispatchEvent(pasteEvent);
            fireInputEvents(composer);
            return true;
        } catch {
            return false;
        }
    }

    function setComposerText(text) {
        const composer = composerNode();
        if (!composer) return false;

        composer.focus();
        clearComposer();

        if (pasteIntoComposer(text)) {
            if (normalizeText(composer.innerText || '').length >= normalizeText(text).length * 0.7) return true;
        }

        let inserted = false;
        try { inserted = document.execCommand('insertText', false, text); } catch { inserted = false; }
        if (!inserted) composer.textContent = text;

        fireInputEvents(composer);
        return true;
    }

    function moveCaretToEndOfComposer(composer) {
        try {
            const selection = window.getSelection();
            const range = document.createRange();
            range.selectNodeContents(composer);
            range.collapse(false);
            selection.removeAllRanges();
            selection.addRange(range);
        } catch { }
    }

    function appendComposerText(textToAppend) {
        const composer = composerNode();
        if (!composer) return false;

        composer.focus();
        moveCaretToEndOfComposer(composer);

        if (pasteIntoComposer(textToAppend)) return true;

        let inserted = false;
        try { inserted = document.execCommand('insertText', false, textToAppend); } catch { inserted = false; }
        if (!inserted) composer.textContent = (composer.innerText || '') + textToAppend;

        fireInputEvents(composer);
        return true;
    }

    // ---------- Draft restore ----------
    function scheduleDraftRestore(draftText, attemptToken) {
        const draft = normalizeText(draftText);
        if (!draft) return;

        pendingDraftRestore = {
            attemptToken,
            draftText: draft,
            attemptIndex: 0,
            startedAt: performance.now(),
        };

        queueMicrotask(tryRestorePendingDraft);
    }

    function tryRestorePendingDraft() {
        if (!pendingDraftRestore) return;

        if (sendCancellationToken !== pendingDraftRestore.attemptToken) {
            pendingDraftRestore = null;
            return;
        }

        if (isInteracting()) return;

        const composer = composerNode();
        if (!composer) return;

        const currentText = normalizeText(composer.innerText || '');
        const draft = pendingDraftRestore.draftText;

        if (draft && currentText.includes(draft)) {
            pendingDraftRestore = null;
            return;
        }

        if (currentText === '') setComposerText(draft);
        else appendComposerText(`\n\n${draft}`);

        const afterText = normalizeText(composer.innerText || '');
        if (afterText.includes(draft)) {
            pendingDraftRestore = null;
            return;
        }

        pendingDraftRestore.attemptIndex += 1;
        if (pendingDraftRestore.attemptIndex >= draftRestoreRetryDelays.length) {
            pendingDraftRestore = null;
            return;
        }

        const delay = draftRestoreRetryDelays[pendingDraftRestore.attemptIndex];
        setTimeout(() => {
            if (pendingDraftRestore) tryRestorePendingDraft();
        }, delay);
    }

    // ---------- Queue controls ----------
    function buildToggleButton() {
        const button = document.createElement('button');
        button.type = 'button';
        button.contentEditable = 'false';
        button.setAttribute('aria-pressed', mergeMessagesEnabled ? 'true' : 'false');
        button.style.cssText =
            'display:inline-flex;align-items:center;gap:8px;' +
            'padding:8px 12px;border-radius:999px;border:1px solid rgba(255,255,255,.12);' +
            'background:rgba(255,255,255,.06);color:inherit;font-size:13px;cursor:pointer;user-select:none;';

        const label = document.createElement('span');
        label.textContent = 'Merge messages';

        const state = document.createElement('span');
        state.textContent = mergeMessagesEnabled ? 'On' : 'Off';
        state.style.cssText =
            'padding:2px 8px;border-radius:999px;border:1px solid rgba(255,255,255,.14);' +
            'background:rgba(0,0,0,.12);font-size:12px;';

        button.addEventListener('mouseenter', () => { button.style.background = 'rgba(255,255,255,.10)'; });
        button.addEventListener('mouseleave', () => { button.style.background = 'rgba(255,255,255,.06)'; });

        button.addEventListener('click', (event) => {
            event.preventDefault();
            event.stopPropagation();

            sendCancellationToken++;
            mergeMessagesEnabled = !mergeMessagesEnabled;

            if (mergeMessagesEnabled && promptQueue.length > 1) {
                promptQueue = [promptQueue.join('\n\n')];
                if (!currentConversationKey) transientQueue = promptQueue;
            }

            if (currentConversationKey) persistCurrentStateIfPossible();
            else transientMergeMessagesEnabled = mergeMessagesEnabled;

            renderQueueHard();
        }, true);

        button.appendChild(label);
        button.appendChild(state);
        return button;
    }

    function buildClearQueueButton() {
        const clearButton = document.createElement('button');
        clearButton.type = 'button';
        clearButton.textContent = 'Clear queue';
        clearButton.contentEditable = 'false';
        clearButton.style.cssText =
            'padding:8px 12px;border-radius:999px;border:1px solid rgba(255,255,255,.12);' +
            'background:rgba(255,255,255,.06);color:inherit;font-size:13px;cursor:pointer;user-select:none;';
        clearButton.addEventListener('mouseenter', () => { clearButton.style.background = 'rgba(255,255,255,.10)'; });
        clearButton.addEventListener('mouseleave', () => { clearButton.style.background = 'rgba(255,255,255,.06)'; });

        clearButton.addEventListener('click', (event) => {
            event.preventDefault();
            event.stopPropagation();

            sendCancellationToken++;
            promptQueue.length = 0;
            persistCurrentStateIfPossible();
            renderQueueHard();
        }, true);

        return clearButton;
    }

    function buildControlsRow() {
        const row = document.createElement('div');
        row.contentEditable = 'false';
        row.dataset.queueControls = 'true';
        row.style.cssText = 'display:flex;justify-content:flex-end;gap:10px;padding:2px 0 0 0;';
        row.appendChild(buildToggleButton());
        row.appendChild(buildClearQueueButton());
        return row;
    }

    // ---------- Render ----------
    function renderQueueHard() {
        const host = ensureQueueHost();
        if (!host) return;

        clearDropLine();
        activeEditIndex = null;
        host.innerHTML = '';
        renderQueue();
    }

    function renderQueue() {
        const host = ensureQueueHost();
        if (!host) return;
        if (activeEditIndex !== null) return;
        if (isDragging) return;

        ensureAnimationStyles();

        if (promptQueue.length > lastRenderedQueueSnapshot.length) {
            pendingEnterCountFromBottom = Math.min(
                promptQueue.length - lastRenderedQueueSnapshot.length,
                promptQueue.length
            );
        } else {
            pendingEnterCountFromBottom = 0;
        }
        lastRenderedQueueSnapshot = promptQueue.slice();

        host.innerHTML = '';
        if (promptQueue.length === 0) return;

        for (let index = 0; index < promptQueue.length; index++) {
            const rowWrapper = document.createElement('div');
            rowWrapper.contentEditable = 'false';
            rowWrapper.dataset.queueRow = 'true';
            rowWrapper.dataset.queueIndex = String(index);
            rowWrapper.style.cssText = queueRowWrapperStyle();

            // apply enter animation to the WHOLE ROW (handle + bubble)
            const isInEnterRange = pendingEnterCountFromBottom > 0 && index >= (promptQueue.length - pendingEnterCountFromBottom);
            if (isInEnterRange) rowWrapper.classList.add('tmq-enter');

            const handle = makeDragHandle(index);

            const itemBox = document.createElement('div');
            itemBox.contentEditable = 'false';
            itemBox.dataset.queueBox = 'true';
            itemBox.style.cssText = queueItemBoxStyle();

            const text = document.createElement('div');
            text.contentEditable = 'false';
            text.style.cssText = queueTextStyle();
            text.textContent = promptQueue[index];

            const buttons = document.createElement('div');
            buttons.contentEditable = 'false';
            buttons.style.cssText = 'display:flex;gap:8px;flex:0 0 auto;margin-left:auto;align-items:center;';

            const editButton = makeIconButton({ title: 'Edit', kind: 'edit' });
            const deleteButton = makeIconButton({ title: 'Delete', kind: 'delete' });

            editButton.addEventListener('click', (event) => {
                event.preventDefault();
                event.stopPropagation();
                beginInlineEdit(index);
            }, true);

            deleteButton.addEventListener('click', (event) => {
                event.preventDefault();
                event.stopPropagation();
                animateAndDeleteQueueItem(index);
            }, true);

            buttons.appendChild(editButton);
            buttons.appendChild(deleteButton);

            itemBox.appendChild(text);
            itemBox.appendChild(buttons);

            rowWrapper.appendChild(handle);
            rowWrapper.appendChild(itemBox);

            host.appendChild(rowWrapper);
        }

        host.appendChild(buildControlsRow());
    }

    function deleteQueueItem(index) {
        if (index < 0 || index >= promptQueue.length) return;
        sendCancellationToken++;
        promptQueue.splice(index, 1);
        persistCurrentStateIfPossible();
        renderQueueHard();
    }

    function animateAndDeleteQueueItem(index) {
        if (index < 0 || index >= promptQueue.length) return;
        sendCancellationToken++;

        const host = ensureQueueHost();
        const row = host?.querySelector(`[data-queue-row="true"][data-queue-index="${index}"]`);
        if (!row) return deleteQueueItem(index);

        row.classList.remove('tmq-enter');
        row.classList.add('tmq-exit-delete');

        setTimeout(() => deleteQueueItem(index), exitDeleteAnimationMilliseconds);
    }

    function beginInlineEdit(index) {
        const host = ensureQueueHost();
        if (!host) return;
        if (index < 0 || index >= promptQueue.length) return;

        sendCancellationToken++;
        clearDropLine();

        activeEditIndex = index;
        activeEditOriginalText = promptQueue[index];
        host.innerHTML = '';

        for (let i = 0; i < promptQueue.length; i++) {
            const isEditingThis = i === activeEditIndex;

            const rowWrapper = document.createElement('div');
            rowWrapper.contentEditable = 'false';
            rowWrapper.dataset.queueRow = 'true';
            rowWrapper.style.cssText = queueRowWrapperStyle();

            const handle = makeDragHandle(i);
            handle.setAttribute('draggable', 'false');
            handle.style.cursor = 'not-allowed';
            handle.style.opacity = '.55';

            const itemBox = document.createElement('div');
            itemBox.contentEditable = 'false';
            itemBox.dataset.queueBox = 'true';
            itemBox.style.cssText = queueItemBoxStyle();

            const text = document.createElement('div');
            text.contentEditable = isEditingThis ? 'true' : 'false';
            text.spellcheck = false;
            text.style.cssText = isEditingThis
                ? (queueTextStyle() + ';outline:none;border-radius:10px;border:1px solid rgba(255,255,255,.18);background:rgba(0,0,0,.12);padding:8px 10px;max-height:220px;overflow:auto;align-items:flex-start;')
                : queueTextStyle();
            text.textContent = promptQueue[i];

            const buttons = document.createElement('div');
            buttons.contentEditable = 'false';
            buttons.style.cssText = 'display:flex;gap:8px;flex:0 0 auto;margin-left:auto;align-items:center;';

            const primaryButton = makeIconButton({ title: isEditingThis ? 'Save' : 'Edit', kind: isEditingThis ? 'save' : 'edit' });
            const secondaryButton = makeIconButton({ title: isEditingThis ? 'Cancel' : 'Delete', kind: isEditingThis ? 'cancel' : 'delete' });

            primaryButton.addEventListener('click', (event) => {
                event.preventDefault();
                event.stopPropagation();
                if (!isEditingThis) return beginInlineEdit(i);
                commitInlineEdit(text.innerText);
            }, true);

            secondaryButton.addEventListener('click', (event) => {
                event.preventDefault();
                event.stopPropagation();
                if (!isEditingThis) return animateAndDeleteQueueItem(i);
                cancelInlineEdit();
            }, true);

            if (isEditingThis) {
                text.addEventListener('keydown', (event) => {
                    if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); cancelInlineEdit(); }
                    if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); event.stopPropagation(); commitInlineEdit(text.innerText); }
                }, true);

                text.addEventListener('blur', () => {
                    if (activeEditIndex !== null) commitInlineEdit(text.innerText);
                }, { capture: true, once: true });
            }

            buttons.appendChild(primaryButton);
            buttons.appendChild(secondaryButton);

            itemBox.appendChild(text);
            itemBox.appendChild(buttons);

            rowWrapper.appendChild(handle);
            rowWrapper.appendChild(itemBox);

            host.appendChild(rowWrapper);

            if (isEditingThis) {
                queueMicrotask(() => {
                    text.focus();
                    const sel = window.getSelection();
                    const range = document.createRange();
                    range.selectNodeContents(text);
                    sel.removeAllRanges();
                    sel.addRange(range);
                });
            }
        }

        host.appendChild(buildControlsRow());
    }

    function commitInlineEdit(editedText) {
        if (activeEditIndex === null) return;
        const cleaned = normalizeText(editedText);
        if (!cleaned) return animateAndDeleteQueueItem(activeEditIndex);
        promptQueue[activeEditIndex] = cleaned;
        activeEditIndex = null;
        persistCurrentStateIfPossible();
        renderQueueHard();
        pumpQueue();
    }

    function cancelInlineEdit() {
        if (activeEditIndex === null) return;
        promptQueue[activeEditIndex] = activeEditOriginalText;
        activeEditIndex = null;
        persistCurrentStateIfPossible();
        renderQueueHard();
        pumpQueue();
    }

    // ---------- Queue behavior ----------
    function enqueuePrompt(promptText) {
        const normalized = normalizeText(promptText);
        if (!normalized) return;

        const beforeLength = promptQueue.length;

        if (mergeMessagesEnabled) {
            if (promptQueue.length === 0) promptQueue.push(normalized);
            else promptQueue[promptQueue.length - 1] = `${promptQueue[promptQueue.length - 1]}\n\n${normalized}`;
        } else {
            promptQueue.push(normalized);
        }

        const afterLength = promptQueue.length;
        if (afterLength > beforeLength) pendingEnterCountFromBottom = afterLength - beforeLength;
        else pendingEnterCountFromBottom = 0;

        if (currentConversationKey) persistCurrentStateIfPossible();
        else transientMergeMessagesEnabled = mergeMessagesEnabled;

        renderQueueHard();
        pumpQueue();
    }

    async function animateDequeueSendIfVisible() {
        const host = ensureQueueHost();
        const firstRow = host?.querySelector('[data-queue-row="true"]');
        if (!firstRow) return;

        ensureAnimationStyles();
        firstRow.classList.remove('tmq-enter');
        firstRow.classList.add('tmq-exit-send');
        await sleep(exitSendAnimationMilliseconds);
    }

    async function sendNextQueuedPrompt() {
        if (!promptQueue.length || isStreaming() || isInteracting()) return false;

        const myAttemptToken = sendCancellationToken;

        const savedDraftText = currentComposerText();
        const nextQueuedPromptText = promptQueue[0];
        const baselineTransactionCount = proseMirrorTransactionCounter;

        setComposerText(nextQueuedPromptText);

        const editorAcknowledgedText = await waitFor(
            () => proseMirrorTransactionCounter > baselineTransactionCount || currentComposerText() === normalizeText(nextQueuedPromptText),
            transactionWaitMilliseconds
        );
        if (sendCancellationToken !== myAttemptToken) return false;
        if (!editorAcknowledgedText) return false;

        const sendButtonEnabled = await waitFor(() => !isStreaming() && isSendEnabled(), sendEnableWaitMilliseconds);
        if (sendCancellationToken !== myAttemptToken) return false;
        if (!sendButtonEnabled) return false;

        clickSubmitButtonHuman();
        if (sendCancellationToken !== myAttemptToken) return false;

        const streamingStarted = await waitFor(() => isStreaming(), streamingStartWaitMilliseconds);
        if (sendCancellationToken !== myAttemptToken) return false;
        if (!streamingStarted) return false;

        if (savedDraftText) scheduleDraftRestore(savedDraftText, myAttemptToken);

        await animateDequeueSendIfVisible();
        if (sendCancellationToken !== myAttemptToken) return false;

        promptQueue.shift();
        persistCurrentStateIfPossible();
        renderQueueHard();

        return true;
    }

    async function pumpQueue() {
        if (isQueuePumpRunning) return;
        if (isInteracting()) return;

        isQueuePumpRunning = true;
        try { await sendNextQueuedPrompt(); }
        finally { isQueuePumpRunning = false; }
    }

    // ---------- Enter hijack while streaming ----------
    function onComposerKeydownCapture(event) {
        if (event.key !== 'Enter' || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey || event.isComposing) return;

        const composer = composerNode();
        if (!composer || document.activeElement !== composer || !isStreaming()) return;

        event.preventDefault();
        event.stopImmediatePropagation?.();
        event.stopPropagation();

        const text = currentComposerText();
        if (text) enqueuePrompt(text);
        clearComposer();
    }

    // ---------- Attach listeners ----------
    function attachComposerListeners() {
        const composer = composerNode();
        if (!composer || composer === attachedComposerNode) return;

        attachedComposerNode = composer;

        composer.addEventListener('prosemirrorDispatchTransaction', () => {
            proseMirrorTransactionCounter++;
            if (pendingDraftRestore) tryRestorePendingDraft();
        }, false);

        composer.addEventListener('keydown', onComposerKeydownCapture, true);
    }

    function attachSubmitButtonObserver() {
        const button = submitButtonNode();
        if (!button || button === attachedSubmitButtonNode) return;

        attachedSubmitButtonNode = button;

        submitButtonMutationObserver?.disconnect();
        submitButtonMutationObserver = new MutationObserver(() => {
            if (!isStreaming() && promptQueue.length && !isInteracting()) pumpQueue();
            if (pendingDraftRestore) tryRestorePendingDraft();
        });

        submitButtonMutationObserver.observe(button, {
            attributes: true,
            attributeFilter: ['data-testid', 'disabled', 'aria-disabled'],
        });
    }

    // ---------- SPA navigation ----------
    function notifyUrlChange() { switchConversationIfNeeded(); }

    function installHistoryHooks() {
        const originalPushState = history.pushState;
        const originalReplaceState = history.replaceState;

        history.pushState = function (...args) {
            const result = originalPushState.apply(this, args);
            queueMicrotask(notifyUrlChange);
            return result;
        };

        history.replaceState = function (...args) {
            const result = originalReplaceState.apply(this, args);
            queueMicrotask(notifyUrlChange);
            return result;
        };

        window.addEventListener('popstate', () => queueMicrotask(notifyUrlChange), true);
    }

    document.addEventListener('visibilitychange', () => {
        if (!document.hidden) {
            switchConversationIfNeeded();
            renderQueueHard();
            if (pendingDraftRestore) tryRestorePendingDraft();
            if (promptQueue.length && !isStreaming() && !isInteracting()) pumpQueue();
        }
    }, true);

    // ---------- Main loop ----------
    function tick() {
        switchConversationIfNeeded();
        ensureQueueHost();
        attachComposerListeners();
        attachSubmitButtonObserver();

        if (!isDragging) renderQueue();

        if (pendingDraftRestore) tryRestorePendingDraft();
        if (promptQueue.length && !isStreaming() && !isInteracting()) pumpQueue();
    }

    // ---------- Bootstrap ----------
    ensureAnimationStyles();
    installHistoryHooks();
    switchConversationIfNeeded();
    setInterval(tick, pollIntervalMilliseconds);
    tick();

})();