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.

Від 27.08.2025. Дивіться остання версія.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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