AI Floating Bubble (Sidebar Window)

Adds a draggable floating AI bubble with a comprehensive list of bots. Clicking an option opens a new browser window styled to look like a sidebar.

Versión del día 27/8/2025. Echa un vistazo a la versión más reciente.

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

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este 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         AI Floating Bubble (Sidebar Window)
// @version      1.6
// @description  Adds a draggable floating AI bubble with a comprehensive list of bots. Clicking an option opens a new browser window styled to look like a sidebar.
// @author       Mayukhjit Chakraborty
// @match        *://*/*
// @grant        GM_addStyle
// @license      MIT
// @namespace    http://tampermonkey.net/
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // This script should not run on the "sidebar" window itself
    const urlParams = new URLSearchParams(window.location.search);
    const isAISidebarWindow = urlParams.has('ai_sidebar_window');
    if (isAISidebarWindow) {
        return;
    }

    // Do not run inside iframes; only in top-level browsing context
    if (window.top !== window.self) {
        return;
    }

    /**
     * @class AISites
     * Manages the names and URLs for AI sites, including login requirements.
     */
    class AISites {
        static get LIST() {
            return [
                { name: "ChatGPT", url: "https://chat.openai.com/", loginNeeded: false },
                { name: "Gemini", url: "https://gemini.google.com/", loginNeeded: true },
                { name: "Copilot", url: "https://copilot.microsoft.com/", loginNeeded: false },
                { name: "Perplexity", url: "https://www.perplexity.ai/", loginNeeded: false },
                { name: "Poe", url: "https://poe.com/", loginNeeded: true },
                { name: "Grok", url: "https://x.com/i/grok", loginNeeded: false },
                { name: "You.com", url: "https://you.com/chat", loginNeeded: true },
                { name: "Claude", url: "https://claude.ai/", loginNeeded: true },
                { name: "Qwen", url: "https://chat.qwen.ai/", loginNeeded: false },
                { name: "Deepseek", url: "https://chat.deepseek.com/", loginNeeded: true },
            ];
        }
    }

    /**
     * @class BubbleConfig
     * Manages all numerical configuration values for the bubble and sidebar window.
     */
    class BubbleConfig {
        static get BUBBLE_SIZE() { return 50; }
        static get MENU_GAP() { return 10; }
        static get MENU_TRANSITION_DURATION() { return 0.2; }
        static get MENU_HIDE_DELAY() { return 100; }
        static get SIDEBAR_WIDTH() { return 400; }
        static get SIDEBAR_HEIGHT_PERCENT() { return 0.9; }
        static get ITEM_PADDING_VERTICAL() { return 12; }
        static get ITEM_PADDING_HORIZONTAL() { return 20; }
    }

    /**
     * @class AIFloatingBubble
     * The main class for managing the AI floating bubble and sidebar window.
     */
    class AIFloatingBubble {
        constructor() {
            this.bubbleContainer = null;
            this.bubbleButton = null;
            this.siteOptions = null;
            this.hideTimeout = null;
            this.isDragging = false;
            this.offsetX = 0;
            this.offsetY = 0;
            this.isHidden = false;

            this._init();
        }

        /**
         * @private
         * Initializes the bubble and sidebar functionality.
         */
        _init() {
            this._createElements();
            this._applyStyles();
            this._loadPosition();
            this._loadHiddenState();
            this._setupEventListeners();
        }

        /**
         * @private
         * Creates and appends the necessary DOM elements.
         */
        _createElements() {
            // Main bubble container
            this.bubbleContainer = document.createElement('div');
            this.bubbleContainer.id = 'aiFloatingBubbleContainer';
            document.body.appendChild(this.bubbleContainer);

            // Bubble button
            this.bubbleButton = document.createElement('div');
            this.bubbleButton.id = 'aiFloatingBubbleButton';
            this.bubbleButton.textContent = 'AI';
            this.bubbleButton.setAttribute('role', 'button');
            this.bubbleButton.setAttribute('aria-label', 'Open AI menu');
            this.bubbleButton.setAttribute('aria-expanded', 'false');
            this.bubbleButton.setAttribute('tabindex', '0');
            this.bubbleContainer.appendChild(this.bubbleButton);

            // AI site options menu
            this.siteOptions = document.createElement('div');
            this.siteOptions.id = 'aiSiteOptions';
            this.siteOptions.setAttribute('role', 'menu');
            this.siteOptions.setAttribute('aria-hidden', 'true');

            AISites.LIST.forEach(site => {
                const option = document.createElement('button');
                option.className = 'ai-option';
                option.textContent = site.name;
                option.setAttribute('role', 'menuitem');
                option.setAttribute('type', 'button');

                // Add login needed text if applicable
                if (site.loginNeeded) {
                    const loginSpan = document.createElement('span');
                    loginSpan.className = 'login-needed-text';
                    loginSpan.textContent = ' [Login]';
                    option.appendChild(loginSpan);
                }

                option.dataset.url = site.url;
                this.siteOptions.appendChild(option);
            });

            // Divider and hide control
            const controlsDivider = document.createElement('div');
            controlsDivider.className = 'ai-divider';
            this.siteOptions.appendChild(controlsDivider);

            const hideButton = document.createElement('button');
            hideButton.className = 'ai-option ai-option-control';
            hideButton.textContent = 'Hide Bubble';
            hideButton.setAttribute('role', 'menuitem');
            hideButton.setAttribute('type', 'button');
            hideButton.dataset.action = 'hide';
            this.siteOptions.appendChild(hideButton);
            this.bubbleContainer.appendChild(this.siteOptions);
        }

        /**
         * @private
         * Dynamically adds required CSS styles.
         */
        _applyStyles() {
            GM_addStyle(`
                /* Floating bubble container */
                #aiFloatingBubbleContainer {
                    position: fixed;
                    z-index: 2147483647;
                    transition: transform 0.2s ease-in-out;
                    cursor: grab;
                }
                #aiFloatingBubbleContainer.grabbing { cursor: grabbing; }
                #aiFloatingBubbleContainer.hidden { display: none !important; }

                /* Bubble button */
                #aiFloatingBubbleButton {
                    width: ${BubbleConfig.BUBBLE_SIZE}px;
                    height: ${BubbleConfig.BUBBLE_SIZE}px;
                    background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
                    color: white;
                    border: none;
                    border-radius: 50%;
                    box-shadow: 0 4px 15px rgba(0, 0, 0, 0.25);
                    display: flex;
                    justify-content: center;
                    align-items: center;
                    font-family: 'Arial', sans-serif;
                    font-weight: bold;
                    font-size: 1.2rem;
                    cursor: pointer;
                    user-select: none;
                    transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.5s ease;
                }
                #aiFloatingBubbleButton:hover {
                    transform: scale(1.1);
                    box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
                }
                #aiFloatingBubbleButton:focus {
                    outline: 2px solid #2575fc;
                    outline-offset: 2px;
                }

                /* Options menu */
                #aiSiteOptions {
                    position: absolute;
                    bottom: ${BubbleConfig.BUBBLE_SIZE + BubbleConfig.MENU_GAP}px;
                    right: 0;
                    background-color: #ffffff;
                    border-radius: 10px;
                    box-shadow: 0 5px 20px rgba(0, 0, 0, 0.15);
                    overflow: hidden;
                    min-width: 180px;
                    opacity: 0;
                    transform: scale(0.9);
                    pointer-events: none;
                    transition: opacity ${BubbleConfig.MENU_TRANSITION_DURATION}s ease-in-out, transform ${BubbleConfig.MENU_TRANSITION_DURATION}s ease-in-out;
                    transform-origin: bottom right;
                    display: flex;
                    flex-direction: column;
                    z-index: 2147483646;
                }
                #aiSiteOptions.visible {
                    opacity: 1;
                    transform: scale(1);
                    pointer-events: auto;
                }

                /* Menu items */
                .ai-option {
                    all: unset;
                    display: flex;
                    align-items: center;
                    padding: ${BubbleConfig.ITEM_PADDING_VERTICAL}px ${BubbleConfig.ITEM_PADDING_HORIZONTAL}px;
                    cursor: pointer;
                    color: #333333;
                    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
                    font-size: 14px;
                    border-bottom: 1px solid #f0f0f0;
                    transition: background-color 0.2s, color 0.2s;
                    justify-content: space-between;
                    text-align: left;
                    width: 100%;
                    box-sizing: border-box;
                }
                .ai-option:hover, .ai-option:focus {
                    background-color: #f0f8ff;
                    color: #2575fc;
                }
                .ai-option:last-child { border-bottom: none; }
                .ai-option-control { font-weight: 600; }
                .ai-divider { height: 1px; background: #f0f0f0; }

                /* Login needed text style */
                .login-needed-text {
                    font-size: 12px;
                    color: #777;
                    font-style: italic;
                    margin-left: 8px;
                }
            `);
        }

        /**
         * @private
         * Sets up all event listeners.
         */
        _setupEventListeners() {
            this._setupDrag();
            this._setupHover();
            this._setupClick();
            this._setupKeyboard();
            this._setupResizeHandler();
        }

        /**
         * @private
         * Configures the bubble's drag functionality.
         */
        _setupDrag() {
            this.bubbleContainer.addEventListener('mousedown', (e) => {
                if (e.target.closest('.ai-option')) { return; }
                this.isDragging = true;
                this.bubbleContainer.classList.add('grabbing');
                this.offsetX = e.clientX - this.bubbleContainer.getBoundingClientRect().left;
                this.offsetY = e.clientY - this.bubbleContainer.getBoundingClientRect().top;
                e.preventDefault();
            });

            document.addEventListener('mousemove', (e) => {
                if (!this.isDragging) return;
                const newLeft = e.clientX - this.offsetX;
                const newTop = e.clientY - this.offsetY;
                const maxX = window.innerWidth - this.bubbleContainer.offsetWidth;
                const maxY = window.innerHeight - this.bubbleContainer.offsetHeight;

                this.bubbleContainer.style.left = `${Math.max(0, Math.min(newLeft, maxX))}px`;
                this.bubbleContainer.style.top = `${Math.max(0, Math.min(newTop, maxY))}px`;
                this.bubbleContainer.style.right = 'auto';
                this.bubbleContainer.style.bottom = 'auto';
            });

            document.addEventListener('mouseup', () => {
                if (this.isDragging) {
                    this.isDragging = false;
                    this.bubbleContainer.classList.remove('grabbing');
                    this._savePosition();
                }
            });

            // Touch support for mobile devices
            this.bubbleContainer.addEventListener('touchstart', (e) => {
                if (e.target.closest('.ai-option')) { return; }
                this.isDragging = true;
                const touch = e.touches[0];
                this.offsetX = touch.clientX - this.bubbleContainer.getBoundingClientRect().left;
                this.offsetY = touch.clientY - this.bubbleContainer.getBoundingClientRect().top;
                e.preventDefault();
            });

            document.addEventListener('touchmove', (e) => {
                if (!this.isDragging) return;
                const touch = e.touches[0];
                const newLeft = touch.clientX - this.offsetX;
                const newTop = touch.clientY - this.offsetY;
                const maxX = window.innerWidth - this.bubbleContainer.offsetWidth;
                const maxY = window.innerHeight - this.bubbleContainer.offsetHeight;

                this.bubbleContainer.style.left = `${Math.max(0, Math.min(newLeft, maxX))}px`;
                this.bubbleContainer.style.top = `${Math.max(0, Math.min(newTop, maxY))}px`;
                this.bubbleContainer.style.right = 'auto';
                this.bubbleContainer.style.bottom = 'auto';
            });

            document.addEventListener('touchend', () => {
                if (this.isDragging) {
                    this.isDragging = false;
                    this._savePosition();
                }
            });
        }

        /**
         * @private
         * Configures the menu show/hide functionality on hover.
         */
        _setupHover() {
            this.bubbleContainer.addEventListener('mouseenter', () => {
                if (this.isDragging) return;
                clearTimeout(this.hideTimeout);
                this.siteOptions.classList.add('visible');
                this.siteOptions.setAttribute('aria-hidden', 'false');
            });

            this.bubbleContainer.addEventListener('mouseleave', () => {
                this.hideTimeout = setTimeout(() => {
                    this.siteOptions.classList.remove('visible');
                    this.siteOptions.setAttribute('aria-hidden', 'true');
                }, BubbleConfig.MENU_HIDE_DELAY);
            });
        }

        /**
         * @private
         * Sets up the click functionality for menu options.
         */
        _setupClick() {
            this.bubbleButton.addEventListener('click', (e) => {
                e.stopPropagation();
                this.siteOptions.classList.toggle('visible');
                this.siteOptions.setAttribute('aria-hidden',
                    this.siteOptions.classList.contains('visible') ? 'false' : 'true');
                this.bubbleButton.setAttribute('aria-expanded', this.siteOptions.classList.contains('visible') ? 'true' : 'false');
            });

            this.siteOptions.addEventListener('click', (event) => {
                const option = event.target.closest('.ai-option');
                if (option) {
                    if (option.dataset.action === 'hide') {
                        this._toggleHidden(true);
                        this.siteOptions.classList.remove('visible');
                        this.siteOptions.setAttribute('aria-hidden', 'true');
                        this.bubbleButton.setAttribute('aria-expanded', 'false');
                        return;
                    }

                    const url = option.dataset.url;
                    if (url) {
                        this._openSidebarWindow(url);
                        this.siteOptions.classList.remove('visible');
                        this.siteOptions.setAttribute('aria-hidden', 'true');
                        this.bubbleButton.setAttribute('aria-expanded', 'false');
                    }
                }
            });

            // Close menu when clicking outside
            document.addEventListener('click', (e) => {
                if (!this.bubbleContainer.contains(e.target) && this.siteOptions.classList.contains('visible')) {
                    this.siteOptions.classList.remove('visible');
                    this.siteOptions.setAttribute('aria-hidden', 'true');
                    this.bubbleButton.setAttribute('aria-expanded', 'false');
                }
            });
        }

        /**
         * @private
         * Sets up keyboard navigation support.
         */
        _setupKeyboard() {
            this.bubbleButton.addEventListener('keydown', (e) => {
                if (e.key === 'Enter' || e.key === ' ') {
                    e.preventDefault();
                    this.siteOptions.classList.toggle('visible');
                    this.siteOptions.setAttribute('aria-hidden',
                        this.siteOptions.classList.contains('visible') ? 'false' : 'true');
                    this.bubbleButton.setAttribute('aria-expanded', this.siteOptions.classList.contains('visible') ? 'true' : 'false');
                }
            });

            this.siteOptions.addEventListener('keydown', (e) => {
                const options = Array.from(this.siteOptions.querySelectorAll('.ai-option'));
                const currentIndex = options.indexOf(document.activeElement);

                switch(e.key) {
                    case 'Escape':
                        this.siteOptions.classList.remove('visible');
                        this.siteOptions.setAttribute('aria-hidden', 'true');
                        this.bubbleButton.focus();
                        this.bubbleButton.setAttribute('aria-expanded', 'false');
                        break;
                    case 'ArrowDown':
                        e.preventDefault();
                        const nextIndex = (currentIndex + 1) % options.length;
                        options[nextIndex].focus();
                        break;
                    case 'ArrowUp':
                        e.preventDefault();
                        const prevIndex = (currentIndex - 1 + options.length) % options.length;
                        options[prevIndex].focus();
                        break;
                    case 'Enter':
                        e.preventDefault();
                        if (document.activeElement.classList.contains('ai-option')) {
                            const url = document.activeElement.dataset.url;
                            if (url) {
                                this._openSidebarWindow(url);
                                this.siteOptions.classList.remove('visible');
                                this.siteOptions.setAttribute('aria-hidden', 'true');
                                this.bubbleButton.setAttribute('aria-expanded', 'false');
                            }
                        }
                        break;
                }
            });

            // Global hotkey: Ctrl+Shift+A toggles hidden state
            document.addEventListener('keydown', (e) => {
                if (e.ctrlKey && e.shiftKey && (e.key === 'A' || e.key === 'a')) {
                    e.preventDefault();
                    this._toggleHidden(!this.isHidden);
                }
            });
        }

        /**
         * @private
         * Keeps the bubble within viewport on window resize (debounced).
         */
        _setupResizeHandler() {
            let resizeTimer = null;
            const clampToViewport = () => {
                if (!this.bubbleContainer) return;
                const rect = this.bubbleContainer.getBoundingClientRect();
                const maxX = window.innerWidth - rect.width;
                const maxY = window.innerHeight - rect.height;
                let left = this.bubbleContainer.style.left ? parseFloat(this.bubbleContainer.style.left) : null;
                let top = this.bubbleContainer.style.top ? parseFloat(this.bubbleContainer.style.top) : null;
                if (left !== null && top !== null) {
                    left = Math.max(0, Math.min(left, maxX));
                    top = Math.max(0, Math.min(top, maxY));
                    this.bubbleContainer.style.left = `${left}px`;
                    this.bubbleContainer.style.top = `${top}px`;
                    this.bubbleContainer.style.right = 'auto';
                    this.bubbleContainer.style.bottom = 'auto';
                    this._savePosition();
                }
            };

            window.addEventListener('resize', () => {
                if (resizeTimer) { clearTimeout(resizeTimer); }
                resizeTimer = setTimeout(clampToViewport, 150);
            });
        }

        /**
         * @private
         * Opens a new window that is sized and positioned to look like a sidebar.
         * @param {string} url - The URL to open in the new window.
         */
        _openSidebarWindow(url) {
            try {
                const screenWidth = window.screen.width;
                const screenHeight = window.screen.height;

                const sidebarWidth = Math.min(BubbleConfig.SIDEBAR_WIDTH, screenWidth - 100);
                const sidebarHeight = Math.min(screenHeight * BubbleConfig.SIDEBAR_HEIGHT_PERCENT, screenHeight - 100);
                const sidebarLeft = Math.max(0, screenWidth - sidebarWidth);
                const sidebarTop = Math.max(0, (screenHeight - sidebarHeight) / 2);

                const windowName = 'AIFloatingSidebar';
                const features = `width=${sidebarWidth},height=${sidebarHeight},left=${sidebarLeft},top=${sidebarTop},menubar=no,toolbar=no,location=yes,status=no,resizable=yes,scrollbars=yes`;

                const urlWithParam = `${url}${url.includes('?') ? '&' : '?'}ai_sidebar_window=true`;

                const newWindow = window.open(urlWithParam, windowName, features);

                if (!newWindow || newWindow.closed || typeof newWindow.closed === 'undefined') {
                    console.warn('Popup blocked. Please allow popups for this site.');
                    // Fallback: open in current tab
                    window.location.href = url;
                }
            } catch (error) {
                console.error('Error opening sidebar window:', error);
                // Fallback: open in current tab
                window.location.href = url;
            }
        }

        /**
         * @private
         * Saves hidden state to localStorage and toggles visibility.
         * @param {boolean} hidden
         */
        _toggleHidden(hidden) {
            this.isHidden = !!hidden;
            if (this.isHidden) {
                this.bubbleContainer.classList.add('hidden');
            } else {
                this.bubbleContainer.classList.remove('hidden');
            }
            this._saveHiddenState();
        }

        /**
         * @private
         * Persist hidden state.
         */
        _saveHiddenState() {
            try {
                localStorage.setItem('aiFloatingBubbleHidden', JSON.stringify({ hidden: this.isHidden }));
            } catch (error) {
                console.warn('Could not save hidden state:', error);
            }
        }

        /**
         * @private
         * Load hidden state and apply.
         */
        _loadHiddenState() {
            try {
                const raw = localStorage.getItem('aiFloatingBubbleHidden');
                if (raw) {
                    const parsed = JSON.parse(raw);
                    this.isHidden = !!parsed.hidden;
                    if (this.isHidden) {
                        this.bubbleContainer.classList.add('hidden');
                    }
                }
            } catch (error) {
                console.warn('Could not load hidden state:', error);
            }
        }

        /**
         * @private
         * Saves the current position of the bubble to localStorage.
         */
        _savePosition() {
            try {
                const position = {
                    left: this.bubbleContainer.offsetLeft,
                    top: this.bubbleContainer.offsetTop
                };
                localStorage.setItem('aiFloatingBubblePosition', JSON.stringify(position));
            } catch (error) {
                console.warn('Could not save bubble position:', error);
            }
        }

        /**
         * @private
         * Loads the saved bubble position from localStorage.
         */
        _loadPosition() {
            try {
                const savedPosition = localStorage.getItem('aiFloatingBubblePosition');
                if (savedPosition) {
                    const position = JSON.parse(savedPosition);
                    this.bubbleContainer.style.left = `${position.left}px`;
                    this.bubbleContainer.style.top = `${position.top}px`;
                } else {
                    this.bubbleContainer.style.bottom = '20px';
                    this.bubbleContainer.style.right = '20px';
                }
            } catch (error) {
                console.warn('Could not load bubble position:', error);
                this.bubbleContainer.style.bottom = '20px';
                this.bubbleContainer.style.right = '20px';
            }
        }
    }

    // Wait for DOM to be fully loaded
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => {
            new AIFloatingBubble();
        });
    } else {
        new AIFloatingBubble();
    }
})();