// ==UserScript==
// @name Gemini Nav Pro: Live Navigator & Toolbox
// @namespace https://greasyfork.org/en/users/1509088-eithon
// @version 4.0
// @description Adds a powerful floating panel to Gemini chats that live-updates as responses generate. Features one-click code copying, active section highlighting, and rapid prompt-to-prompt navigation.
// @author Eithon (and Gemini AI Assistant)
// @match https://gemini.google.com/*
// @grant GM_addStyle
// @license MIT
// @icon https://www.google.com/s2/favicons?sz=64&domain=gemini.google.com
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
GM_addStyle(`
/* --- Styles are unchanged --- */
#gemini-nav-panel {
position: fixed; top: 50%; right: 20px; transform: translateY(-50%);
z-index: 9999; background-color: rgba(240, 244, 250, 0.9);
border: 1px solid #DDE2E7; border-radius: 12px; padding: 8px;
display: none; flex-direction: column; gap: 6px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1); backdrop-filter: blur(10px); max-height: 90vh;
}
.gemini-nav-dynamic-links {
display: flex; flex-direction: column; gap: 6px; overflow-y: auto;
}
.gemini-nav-separator { border: none; border-top: 1px solid #DDE2E7; margin: 4px 0; }
.gemini-nav-button {
cursor: pointer; height: 36px; padding: 0 12px; display: flex;
align-items: center; justify-content: center; background-color: #FFFFFF;
color: #444746; border-radius: 8px; font-size: 14px; font-weight: 500;
font-family: 'Google Sans', sans-serif; text-decoration: none;
transition: all 0.2s ease; border: 1px solid #DDE2E7; white-space: nowrap;
}
.gemini-nav-button:hover:not(:disabled) {
border-color: #8ab4f8; background-color: #eaf2ff;
box-shadow: 0 0 8px rgba(100, 150, 255, 0.6);
}
.gemini-nav-button:disabled { opacity: 0.5; cursor: not-allowed; }
.gemini-nav-item-container {
display: flex; align-items: center; gap: 4px; background-color: #FFFFFF;
border: 1px solid #DDE2E7; border-radius: 8px; transition: all 0.2s ease;
}
.gemini-nav-item-container:hover { border-color: #8ab4f8; background-color: #eaf2ff; }
.gemini-nav-label {
flex-grow: 1; cursor: pointer; padding: 0 12px; height: 36px;
display: flex; align-items: center; font-size: 14px; font-weight: 500;
}
.gemini-nav-copy-btn {
cursor: pointer; background: none; border: none; padding: 6px 10px 6px 6px;
font-size: 16px; border-left: 1px solid #DDE2E7;
border-top-right-radius: 8px; border-bottom-right-radius: 8px;
transition: background-color 0.2s;
}
.gemini-nav-copy-btn:hover { background-color: rgba(0,0,0,0.08); }
@keyframes subtle-glow {
0% { box-shadow: 0 0 0px rgba(100, 150, 255, 0); }
50% { box-shadow: 0 0 12px rgba(100, 150, 255, 0.7); }
100% { box-shadow: 0 0 0px rgba(100, 150, 255, 0); }
}
.gemini-nav-item-container.glow, .gemini-nav-button.glow {
animation: subtle-glow 2s ease-out;
}
body.dark-theme #gemini-nav-panel { background-color: rgba(30, 31, 34, 0.9); border-color: #5f6368; }
body.dark-theme .gemini-nav-separator { border-top-color: #5f6368; }
body.dark-theme .gemini-nav-button { background-color: #35373A; color: #E2E2E3; border-color: #5f6368; }
body.dark-theme .gemini-nav-button:hover:not(:disabled) {
border-color: #A8C5EE; background-color: #3C485A;
box-shadow: 0 0 8px rgba(168, 197, 238, 0.5);
}
body.dark-theme .gemini-nav-item-container { background-color: #35373A; border-color: #5f6368; }
body.dark-theme .gemini-nav-item-container:hover { border-color: #A8C5EE; background-color: #3C485A; }
body.dark-theme .gemini-nav-copy-btn { border-left-color: #5f6368; }
body.dark-theme .gemini-nav-copy-btn:hover { background-color: rgba(255,255,255,0.1); }
@keyframes subtle-glow-dark {
0% { box-shadow: 0 0 0px rgba(168, 197, 238, 0); }
50% { box-shadow: 0 0 12px rgba(168, 197, 238, 0.5); }
100% { box-shadow: 0 0 0px rgba(168, 197, 238, 0); }
}
body.dark-theme .gemini-nav-item-container.glow, body.dark-theme .gemini-nav-button.glow {
animation-name: subtle-glow-dark;
}
`);
let navPanel = null;
let cachedScrollContainer = null;
let currentNavTarget = null;
let navigableItems = [];
let lastHighlightedElement = null;
let contentObserver = null;
// --- NEW: A throttle utility function ---
// This ensures a function is not called more than once per specified interval.
function throttle(func, limit) {
let inThrottle;
return function() {
const args = arguments;
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
function findScrollContainer() {
if (cachedScrollContainer && document.body.contains(cachedScrollContainer)) return cachedScrollContainer;
const lastResponse = Array.from(document.querySelectorAll('model-response')).pop();
if (!lastResponse) return null;
let parent = lastResponse.parentElement;
while (parent && parent !== document.body) {
if (parent.scrollHeight > parent.clientHeight) {
cachedScrollContainer = parent;
return parent;
}
parent = parent.parentElement;
}
return null;
}
function smoothScrollToElement(targetElement) {
const scrollContainer = findScrollContainer();
if (!scrollContainer || !targetElement) return;
const containerRect = scrollContainer.getBoundingClientRect();
const targetRect = targetElement.getBoundingClientRect();
const offset = targetRect.top - containerRect.top - (containerRect.height * 0.1);
const top = scrollContainer.scrollTop + offset;
scrollContainer.scrollTo({ top: top, behavior: 'smooth' });
}
function mainLoop() {
const responses = document.querySelectorAll('model-response');
let mostVisibleResponse = null;
let maxVisibility = 0;
responses.forEach(response => {
const rect = response.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
const visibleHeight = Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0);
const visibilityPercentage = rect.height > 0 ? visibleHeight / rect.height : 0;
if (visibilityPercentage > maxVisibility) {
maxVisibility = visibilityPercentage;
mostVisibleResponse = response;
}
}
});
if (mostVisibleResponse) {
if (mostVisibleResponse !== currentNavTarget) {
buildNavForResponse(mostVisibleResponse);
}
} else {
removeNavPanel();
}
if (navigableItems.length > 0) {
let mostVisibleItem = null;
let minDistanceFromCenter = Infinity;
const viewportCenter = window.innerHeight / 2;
navigableItems.forEach(item => {
const rect = item.element.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
const distanceFromCenter = Math.abs(rect.top + rect.height / 2 - viewportCenter);
if (distanceFromCenter < minDistanceFromCenter) {
minDistanceFromCenter = distanceFromCenter;
mostVisibleItem = item;
}
}
});
if (mostVisibleItem && mostVisibleItem.element !== lastHighlightedElement) {
lastHighlightedElement = mostVisibleItem.element;
navigableItems.forEach(item => item.uiElement.classList.remove('glow'));
mostVisibleItem.uiElement.classList.add('glow');
setTimeout(() => mostVisibleItem.uiElement.classList.remove('glow'), 2000);
}
}
}
// --- UPDATED: Rebuild function is now throttled ---
const throttledRebuild = throttle((responseElement) => {
if (responseElement === currentNavTarget) {
buildNavForResponse(responseElement);
}
}, 1000); // Rebuild at most once per second
function buildNavForResponse(responseElement) {
if (contentObserver) contentObserver.disconnect();
currentNavTarget = responseElement;
navigableItems = [];
// Do not reset lastHighlightedElement here to prevent re-glowing stable elements
if (!navPanel) {
navPanel = document.createElement('div');
navPanel.id = 'gemini-nav-panel';
document.body.appendChild(navPanel);
}
while (navPanel.firstChild) navPanel.removeChild(navPanel.firstChild);
// This part is the same as before
const userPrompt = findPreviousUserQuery(responseElement);
const codeContainers = responseElement.querySelectorAll('.code-container');
const allPrompts = Array.from(document.querySelectorAll('user-query'));
const currentIndex = allPrompts.findIndex(p => p === userPrompt);
const pageTopButton = createButton('⏫', 'Scroll to Top', () => findScrollContainer()?.scrollTo({ top: 0, behavior: 'smooth' }));
const prevPromptButton = createButton('▲', 'Previous Prompt', () => { if (currentIndex > 0) smoothScrollToElement(allPrompts[currentIndex - 1]); });
prevPromptButton.disabled = (currentIndex <= 0);
navPanel.append(pageTopButton, prevPromptButton);
const promptPoint = userPrompt ? { element: userPrompt, label: 'Prompt' } : null;
const codeBlockPoints = [];
codeContainers.forEach((container, index) => {
const scrollTarget = container.closest('code-block');
if (scrollTarget) {
const langSpan = scrollTarget.querySelector('.code-block-decoration span');
const lang = langSpan ? langSpan.textContent.trim() : 'Code';
codeBlockPoints.push({ element: scrollTarget, label: `${index + 1}. ${lang}` });
}
});
if (promptPoint || codeBlockPoints.length > 0) {
navPanel.appendChild(document.createElement('hr')).className = 'gemini-nav-separator';
const dynamicLinksContainer = document.createElement('div');
dynamicLinksContainer.className = 'gemini-nav-dynamic-links';
navPanel.appendChild(dynamicLinksContainer);
if (promptPoint) {
const promptButton = createButton(promptPoint.label, `Scroll to ${promptPoint.label}`, () => smoothScrollToElement(promptPoint.element));
dynamicLinksContainer.appendChild(promptButton);
navigableItems.push({ element: promptPoint.element, uiElement: promptButton });
}
codeBlockPoints.forEach(point => {
const itemContainer = document.createElement('div');
itemContainer.className = 'gemini-nav-item-container';
const label = document.createElement('span');
label.className = 'gemini-nav-label';
label.textContent = point.label;
label.title = `Scroll to ${point.label}`;
label.onclick = () => smoothScrollToElement(point.element);
const copyBtn = document.createElement('button');
copyBtn.className = 'gemini-nav-copy-btn';
copyBtn.textContent = '📋';
copyBtn.title = 'Copy Code';
copyBtn.onclick = () => {
const code = point.element.querySelector('pre code')?.textContent || '';
navigator.clipboard.writeText(code).then(() => {
copyBtn.textContent = '✅';
setTimeout(() => { copyBtn.textContent = '📋'; }, 2000);
});
};
itemContainer.append(label, copyBtn);
dynamicLinksContainer.appendChild(itemContainer);
navigableItems.push({ element: point.element, uiElement: itemContainer });
});
}
const nextPromptButton = createButton('▼', 'Next Prompt', () => { if (currentIndex > -1 && currentIndex < allPrompts.length - 1) smoothScrollToElement(allPrompts[currentIndex + 1]); });
nextPromptButton.disabled = (currentIndex >= allPrompts.length - 1);
const pageBottomButton = createButton('⏬', 'Scroll to Bottom', () => findScrollContainer()?.scrollTo({ top: findScrollContainer().scrollHeight, behavior: 'smooth' }));
navPanel.appendChild(document.createElement('hr')).className = 'gemini-nav-separator';
navPanel.append(nextPromptButton, pageBottomButton);
navPanel.style.display = 'flex';
// --- UPDATED: The observer now calls the throttled function ---
contentObserver = new MutationObserver(() => throttledRebuild(responseElement));
contentObserver.observe(responseElement, { childList: true, subtree: true });
}
function createButton(text, title, onClick) {
const button = document.createElement('button');
button.className = 'gemini-nav-button';
button.textContent = text;
button.title = title;
button.addEventListener('click', onClick);
return button;
}
function removeNavPanel() {
if (navPanel) navPanel.style.display = 'none';
currentNavTarget = null;
navigableItems = [];
lastHighlightedElement = null;
if (contentObserver) contentObserver.disconnect();
}
function findPreviousUserQuery(element) {
let sibling = element.previousElementSibling;
while (sibling) {
if (sibling.tagName.toLowerCase() === 'user-query') return sibling;
sibling = sibling.previousElementSibling;
}
return element.closest('.conversation-container')?.querySelector('user-query');
}
setInterval(mainLoop, 750);
})();