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