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.

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