// ==UserScript==
// @name QuickNav for Google AI Studio
// @namespace http://tampermonkey.net/
// @version 18.17
// @description Restores classic chat navigation in Google AI Studio, adding essential UI controls for precise, message-by-message browsing, and a powerful message index menu for efficient conversation navigation. This script operates entirely locally in your browser, does not collect any personal data, and makes no requests to external servers.
// @author Axl_script
// @homepageURL https://greasyfork.org/en/scripts/548346-quicknav-for-google-ai-studio
// @contributionURL https://nowpayments.io/embeds/donation-widget?api_key=0fe4e67c-64aa-4a74-b2d2-a91608b1ccc6
// @match https://aistudio.google.com/*
// @grant none
// @license MIT
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
const ChatNavigator = {
allTurns: [],
currentIndex: -1,
menuFocusedIndex: -1,
isDownButtonAtEndToggle: false,
JUMP_DISTANCE: 5,
scrollSyncTimeout: null,
isScrollingProgrammatically: false,
isQueueProcessing: false,
isUnstickingFromBottom: false,
originalScrollTop: 0,
originalCurrentIndex: -1,
loadingQueue: [],
totalToLoad: 0,
mainLoopInterval: null,
activeObserver: null,
observedElement: null,
holdTimeout: null,
holdInterval: null,
init() {
this.injectStyles();
this.setupScrollListener();
if (document.getElementById('quicknav-badge-floater')) return;
const badgeFloater = document.createElement('div');
badgeFloater.id = 'quicknav-badge-floater';
const badgeIndex = document.createElement('div');
badgeIndex.id = 'quicknav-badge-index';
const badgePercentage = document.createElement('div');
badgePercentage.id = 'quicknav-badge-percentage';
badgeFloater.append(badgeIndex, badgePercentage);
document.body.appendChild(badgeFloater);
if (this.mainLoopInterval) clearInterval(this.mainLoopInterval);
this.mainLoopInterval = setInterval(() => this.mainLoop(), 750);
},
mainLoop() {
const chatContainer = document.querySelector('ms-autoscroll-container');
if (chatContainer) {
if (!this.activeObserver || this.observedElement !== chatContainer) {
this.cleanupChatObserver();
this.initializeChatObserver(chatContainer);
this.buildTurnIndex();
}
} else {
if (this.activeObserver) {
this.cleanupChatObserver();
this.allTurns = [];
this.currentIndex = -1;
}
}
this.ensureUIState();
},
ensureUIState() {
const targetNode = document.querySelector('ms-prompt-input-wrapper');
const uiExists = document.getElementById('chat-nav-container');
if (targetNode && !uiExists) {
this.createAndInjectUI(targetNode);
} else if (!targetNode && uiExists) {
uiExists.remove();
const menu = document.getElementById('chat-nav-menu-container');
if (menu) menu.remove();
}
},
initializeChatObserver(container) {
this.observedElement = container;
this.activeObserver = new MutationObserver(() => {
this.buildTurnIndex();
});
this.activeObserver.observe(container, { childList: true, subtree: true });
},
cleanupChatObserver() {
if (this.activeObserver) {
this.activeObserver.disconnect();
}
this.activeObserver = null;
this.observedElement = null;
},
getTurnType(turnElement) {
const turnContainer = turnElement.querySelector('.chat-turn-container');
if (!turnContainer) return 'unknown';
if (turnContainer.classList.contains('user')) return 'user_prompt';
if (turnContainer.classList.contains('model')) {
return turnElement.querySelector('ms-thought-chunk') ? 'model_thought' : 'model_response';
}
return 'unknown';
},
buildTurnIndex() {
const allFoundTurns = Array.from(document.querySelectorAll('ms-chat-turn'));
const freshTurns = allFoundTurns.filter(turn => {
const type = this.getTurnType(turn);
return type === 'user_prompt' || type === 'model_response';
});
if (freshTurns.length !== this.allTurns.length || !this.arraysEqual(this.allTurns, freshTurns)) {
const contentCache = new Map();
this.allTurns.forEach(turn => {
if (turn.id && turn.cachedContent) {
contentCache.set(turn.id, { content: turn.cachedContent, isFallback: turn.isFallbackContent });
}
});
this.allTurns = freshTurns;
this.allTurns.forEach(turn => {
if (turn.id && contentCache.has(turn.id)) {
const cachedData = contentCache.get(turn.id);
turn.cachedContent = cachedData.content;
turn.isFallbackContent = cachedData.isFallback;
} else {
turn.cachedContent = null;
turn.isFallbackContent = false;
}
});
this.updateCounterDisplay();
if (this.currentIndex >= this.allTurns.length) this.synchronizeCurrentIndexFromView();
} else {
this.updateCounterDisplay();
}
},
arraysEqual(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
},
setupScrollListener() {
document.addEventListener('scroll', () => {
if (this.isScrollingProgrammatically || this.isQueueProcessing || this.isUnstickingFromBottom) return;
this.updateScrollPercentage();
clearTimeout(this.scrollSyncTimeout);
this.scrollSyncTimeout = setTimeout(() => this.synchronizeCurrentIndexFromView(), 150);
}, true);
},
injectStyles() {
if (document.getElementById('chat-nav-styles')) return;
const styleSheet = document.createElement("style");
styleSheet.id = 'chat-nav-styles';
styleSheet.textContent = `
@keyframes google-text-flow {
to { background-position: 200% center; }
}
#chat-nav-container { display: flex; justify-content: center; align-items: center; gap: 12px; margin: 2px auto; width: 100%; box-sizing: border-box; }
.counter-wrapper { position: relative; pointer-events: none; z-index: 9999; }
.chat-nav-button, #chat-nav-counter {
background-color: transparent;
border: 1px solid var(--ms-on-surface-variant, #888888);
transition: background-color 0.15s ease-in-out;
pointer-events: auto;
user-select: none;
cursor: pointer;
}
.chat-nav-button {
color: var(--ms-on-surface-variant, #888888);
flex-shrink: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 50%;
}
#nav-up, #nav-down {
color: #8ab4f8;
border-color: #8ab4f8;
}
#chat-nav-counter {
font-family: 'Google Sans', sans-serif;
font-size: 12px;
padding: 4px 8px;
border-radius: 8px;
display: inline-flex;
align-items: baseline;
color: var(--ms-on-surface-variant, #888888);
}
.chat-nav-button:hover,
.chat-nav-button:focus {
background-color: var(--ms-surface-3, #F1F3F4);
outline: none;
}
#nav-top:hover, #nav-bottom:hover,
#nav-top:focus, #nav-bottom:focus {
background-color: rgba(136, 136, 136, 0.15);
}
#nav-up:hover, #nav-down:hover,
#nav-up:focus, #nav-down:focus,
#chat-nav-counter:hover, #chat-nav-counter:focus {
background-color: rgba(138, 180, 248, 0.15);
outline: none;
}
.chat-nav-button:active { transform: scale(0.95); }
#chat-nav-current-num.chat-nav-current-grey {
color: var(--ms-on-surface-variant, #888888);
}
#chat-nav-current-num.chat-nav-current-blue {
color: #8ab4f8;
font-weight: 500;
}
#chat-nav-total-num {
color: #8ab4f8;
font-weight: 500;
}
.prompt-turn-highlight, .response-turn-highlight { position: relative; border-radius: 12px; }
.prompt-turn-highlight::after, .response-turn-highlight::after { content: ""; display: table; clear: both; }
.prompt-turn-highlight { box-shadow: 0 0 0 1px #9aa0a6 !important; }
.response-turn-highlight { box-shadow: 0 0 0 1px #8ab4f8 !important; }
#quicknav-badge-floater { position: fixed; z-index: 99998; visibility: hidden; display: flex; flex-direction: column; align-items: center; pointer-events: none; padding: 4px 7px; border-radius: 8px; font-family: 'Google Sans', sans-serif; }
#quicknav-badge-index { font-size: 13px; font-weight: 500; line-height: 1.2; }
#quicknav-badge-percentage { font-size: 10px; font-weight: 400; line-height: 1.2; border-top: 1px solid rgba(255, 255, 255, 0.3); margin-top: 3px; padding-top: 3px; }
.prompt-badge-bg { background-color: #5f6368; color: #FFFFFF; }
.response-badge-bg { background-color: #174ea6; color: #FFFFFF; }
#chat-nav-menu-container { display: none; flex-direction: column; position: fixed; background-color: #191919; border: 2px solid #8ab4f8; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.25); max-height: 90vh; z-index: 99999; max-width: 800px; min-width: 300px; pointer-events: auto; box-sizing: border-box; }
#chat-nav-menu-container:focus { outline: none; }
#chat-nav-menu { list-style: none; margin: 0; padding: 0 8px 8px 8px; overflow-y: auto; scroll-behavior: smooth; flex-grow: 1; border-radius: 0 0 10px 10px; background-color: #191919; }
.chat-nav-menu-item { display: flex; align-items: center; color: #e8eaed; padding: 8px 12px; margin: 2px 0; border-radius: 8px; cursor: pointer; font-size: 13px; font-family: 'Google Sans', sans-serif; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background-color: #202124; }
.chat-nav-menu-item:hover { background-color: #3c4043; }
.chat-nav-menu-item.menu-item-focused { background-color: #5f6368; }
.menu-item-number { font-weight: 500; margin-right: 8px; flex-shrink: 0; }
.prompt-number-color { color: #9aa0a6; }
.response-number-color { color: #8ab4f8; }
.menu-item-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.prompt-item-bg { border-left: 3px solid #9aa0a6; margin-left: 32px; }
.response-item-bg { border-left: 3px solid #8ab4f8; border-bottom: 1px solid #8ab4f8; }
.chat-nav-menu-header { flex-shrink: 0; background-color: #191919; z-index: 1; display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; border-bottom: 1px solid #8ab4f8; border-radius: 10px 10px 0 0; }
.header-controls { flex: 1; display: flex; align-items: center; }
.header-controls.left { justify-content: flex-start; }
.header-controls.right { justify-content: flex-end; }
.quicknav-title { flex: 2; text-align: center; font-family: 'Google Sans', 'Inter Tight', sans-serif; font-size: 14px; font-weight: 600; background: linear-gradient(90deg, #8ab4f8, #e67c73, #f7cb73, #57bb8a, #8ab4f8); background-size: 200% auto; -webkit-background-clip: text; background-clip: text; color: transparent; animation: google-text-flow 10s linear infinite; user-select: none; }
.header-button { font-family: 'Google Sans', sans-serif; text-decoration: none; font-size: 12px; color: #e8eaed; background-color: #3c4043; padding: 4px 10px; border-radius: 16px; transition: background-color 0.15s ease-in-out, opacity 0.15s ease-in-out; border: none; cursor: pointer; }
.header-button:hover:not(:disabled) { background-color: #5f6368; }
.header-button:disabled { opacity: 0.5; cursor: not-allowed; }
#chat-nav-menu::-webkit-scrollbar { width: 8px; }
#chat-nav-menu::-webkit-scrollbar-track { background: #202124; }
#chat-nav-menu::-webkit-scrollbar-thumb { background-color: #5f6368; border-radius: 4px; }
#chat-nav-menu::-webkit-scrollbar-thumb:hover { background-color: #9aa0a6; }
#chat-nav-loader-status { font-family: 'Google Sans', sans-serif; font-size: 12px; color: #9aa0a6; padding: 4px 10px; }
`;
document.head.appendChild(styleSheet);
},
createAndInjectUI(targetNode) {
const navContainer = document.createElement('div');
navContainer.id = 'chat-nav-container';
const pathTop = 'M12 4l-6 6 1.41 1.41L12 6.83l4.59 4.58L18 10z M12 12l-6 6 1.41 1.41L12 14.83l4.59 4.58L18 18z';
const pathUp = 'M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z';
const pathDown = 'M12 16l-6-6 1.41-1.41L12 13.17l4.59-4.58L18 10z';
const pathBottom = 'M12 12l-6-6 1.41-1.41L12 9.17l4.59-4.58L18 6z M12 20l-6-6 1.41-1.41L12 17.17l4.59-4.58L18 14z';
const btnTop = this.createButton('nav-top', 'Go to first message (Home)', pathTop);
const btnUp = this.createButton('nav-up', 'Navigate to previous message', pathUp);
const counterWrapper = document.createElement('div');
counterWrapper.className = 'counter-wrapper';
const counter = document.createElement('span');
counter.id = 'chat-nav-counter';
counter.tabIndex = 0;
counter.setAttribute('role', 'button');
counter.setAttribute('aria-haspopup', 'true');
counter.setAttribute('aria-expanded', 'false');
const currentNumSpan = document.createElement('span');
currentNumSpan.id = 'chat-nav-current-num';
counter.appendChild(currentNumSpan);
const separatorSpan = document.createElement('span');
separatorSpan.id = 'chat-nav-separator';
separatorSpan.textContent = ' / ';
counter.appendChild(separatorSpan);
const totalNumSpan = document.createElement('span');
totalNumSpan.id = 'chat-nav-total-num';
counter.appendChild(totalNumSpan);
const btnDown = this.createButton('nav-down', 'Navigate to next message', pathDown);
const btnBottom = this.createButton('nav-bottom', 'Go to last message (End)', pathBottom);
let menuContainer = document.getElementById('chat-nav-menu-container');
if (!menuContainer) {
menuContainer = document.createElement('div');
menuContainer.id = 'chat-nav-menu-container';
menuContainer.tabIndex = -1;
menuContainer.setAttribute('role', 'menu');
document.body.appendChild(menuContainer);
}
counterWrapper.append(counter);
navContainer.append(btnTop, btnUp, counterWrapper, btnDown, btnBottom);
const parentContainer = targetNode.closest('footer');
if (parentContainer && parentContainer.parentNode) {
parentContainer.parentNode.insertBefore(navContainer, parentContainer);
this.attachNavigationLogic(btnTop, btnUp, btnDown, btnBottom, counter, menuContainer);
this.updateCounterDisplay();
}
},
createButton(id, ariaLabel, pathData) {
const button = document.createElement('button');
button.id = id;
button.className = 'chat-nav-button';
button.setAttribute('aria-label', ariaLabel);
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute('height', '24px');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('width', '24px');
svg.setAttribute('fill', 'currentColor');
const path = document.createElementNS("http://www.w3.org/2000/svg", 'path');
path.setAttribute('d', pathData);
svg.appendChild(path);
button.appendChild(svg);
return button;
},
setupHoldableButton(button, action) {
const HOLD_DELAY = 300;
const HOLD_INTERVAL = 100;
let isHolding = false;
const stopHold = () => {
clearTimeout(this.holdTimeout);
clearInterval(this.holdInterval);
this.holdInterval = null;
isHolding = false;
};
button.addEventListener('click', (e) => {
if (!isHolding) {
action();
}
stopHold();
});
button.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
isHolding = true;
this.holdTimeout = setTimeout(() => {
if (isHolding) {
this.holdInterval = setInterval(action, HOLD_INTERVAL);
}
}, HOLD_DELAY);
});
button.addEventListener('mouseup', stopHold);
button.addEventListener('mouseleave', stopHold);
},
attachNavigationLogic(btnTop, btnUp, btnDown, btnBottom, counter, menuContainer) {
const lastIndex = () => this.allTurns.length - 1;
this.setupHoldableButton(btnTop, () => this.scrollToAbsoluteTop());
this.setupHoldableButton(btnUp, async () => {
this.synchronizeCurrentIndexFromView();
const last = lastIndex();
const scrollContainer = document.querySelector('ms-autoscroll-container');
if (scrollContainer && this.currentIndex === last && last > -1) {
const currentTurn = this.allTurns[this.currentIndex];
const targetScrollTop = currentTurn.offsetTop - (scrollContainer.clientHeight * 0.3);
const isAtFocalPoint = Math.abs(scrollContainer.scrollTop - targetScrollTop) < 5;
if (!isAtFocalPoint) {
await this.navigateToIndex(last, 'center');
return;
}
}
await this.navigateToIndex(this.currentIndex - 1, 'center');
});
this.setupHoldableButton(btnDown, async () => {
this.synchronizeCurrentIndexFromView();
const last = lastIndex();
if (this.currentIndex < last) {
await this.navigateToIndex(this.currentIndex + 1, 'center');
} else {
const scrollContainer = document.querySelector('ms-autoscroll-container');
if (!scrollContainer) return;
if (this.isDownButtonAtEndToggle) {
await this.navigateToIndex(last, 'center');
this.isDownButtonAtEndToggle = false;
} else {
this.isScrollingProgrammatically = true;
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth' });
setTimeout(() => { this.isScrollingProgrammatically = false; }, 800);
this.isDownButtonAtEndToggle = true;
}
}
});
this.setupHoldableButton(btnBottom, async () => {
this.synchronizeCurrentIndexFromView();
const last = lastIndex();
if (this.currentIndex === last) {
const scrollContainer = document.querySelector('ms-autoscroll-container');
if (scrollContainer) {
this.isScrollingProgrammatically = true;
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth' });
setTimeout(() => { this.isScrollingProgrammatically = false; }, 800);
}
} else {
await this.navigateToIndex(last, 'center');
}
});
counter.addEventListener('click', (e) => { e.stopPropagation(); this.toggleNavMenu(); });
counter.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); this.toggleNavMenu(); } });
menuContainer.addEventListener('keydown', (e) => {
const items = menuContainer.querySelectorAll('.chat-nav-menu-item');
if (!items.length) return;
let newIndex = this.menuFocusedIndex;
let shouldUpdateFocus = true;
switch (e.key) {
case 'ArrowDown': e.preventDefault(); newIndex = (this.menuFocusedIndex + 1) % items.length; break;
case 'ArrowUp': e.preventDefault(); newIndex = (this.menuFocusedIndex - 1 + items.length) % items.length; break;
case 'PageDown': e.preventDefault(); newIndex = Math.min(items.length - 1, this.menuFocusedIndex + this.JUMP_DISTANCE); break;
case 'PageUp': e.preventDefault(); newIndex = Math.max(0, this.menuFocusedIndex - this.JUMP_DISTANCE); break;
case 'Home': e.preventDefault(); newIndex = 0; break;
case 'End': e.preventDefault(); newIndex = items.length - 1; break;
case 'Enter': e.preventDefault(); if (this.menuFocusedIndex !== -1) { items[this.menuFocusedIndex].click(); } shouldUpdateFocus = false; break;
case 'Escape': e.preventDefault(); this.toggleNavMenu(); shouldUpdateFocus = false; break;
default: shouldUpdateFocus = false; break;
}
if (shouldUpdateFocus && newIndex !== this.menuFocusedIndex) { this.updateMenuFocus(items, newIndex); }
});
},
scrollToAbsoluteTop() {
const scrollContainer = document.querySelector('ms-autoscroll-container');
if (!scrollContainer) return;
this.isScrollingProgrammatically = true;
scrollContainer.scrollTo({
top: 0,
behavior: this.holdInterval ? 'auto' : 'smooth'
});
if (this.allTurns.length > 0 && this.currentIndex !== 0) {
this.updateHighlight(this.currentIndex, 0);
this.currentIndex = 0;
this.updateCounterDisplay();
}
setTimeout(() => {
this.isScrollingProgrammatically = false;
}, this.holdInterval ? 50 : 800);
},
waitForTurnToStabilize(turnElement, timeout = 1000) {
return new Promise((resolve, reject) => {
let lastRect = turnElement.getBoundingClientRect();
let stableChecks = 0;
const STABLE_CHECKS_REQUIRED = 3;
const CHECK_INTERVAL = 100;
const intervalId = setInterval(() => {
if (!turnElement || !turnElement.isConnected) {
clearInterval(intervalId);
clearTimeout(timeoutId);
return reject(new Error('Target element was removed from DOM.'));
}
const currentRect = turnElement.getBoundingClientRect();
if (currentRect.top !== lastRect.top || currentRect.height !== lastRect.height) {
lastRect = currentRect;
stableChecks = 0;
} else {
stableChecks++;
}
if (stableChecks >= STABLE_CHECKS_REQUIRED) {
clearInterval(intervalId);
clearTimeout(timeoutId);
resolve();
}
}, CHECK_INTERVAL);
const timeoutId = setTimeout(() => {
clearInterval(intervalId);
reject(new Error(`Element stabilization timed out.`));
}, timeout);
});
},
async scrollToTurn(index, blockPosition = 'center') {
const targetTurn = this.allTurns[index];
if (!targetTurn) {
this.isScrollingProgrammatically = false;
return;
}
this.isScrollingProgrammatically = true;
const isSmooth = !this.isQueueProcessing && !this.holdInterval;
const scrollContainer = document.querySelector('ms-autoscroll-container');
if (!scrollContainer) {
this.isScrollingProgrammatically = false;
return;
}
targetTurn.scrollIntoView({ behavior: 'auto', block: 'center' });
try {
await this.waitForTurnToStabilize(targetTurn, 2000);
let targetScrollTop;
if (blockPosition === 'start') {
targetScrollTop = targetTurn.offsetTop;
} else if (blockPosition === 'end') {
targetScrollTop = targetTurn.offsetTop - (scrollContainer.clientHeight - targetTurn.offsetHeight);
} else { // 'center' or default
targetScrollTop = targetTurn.offsetTop - (scrollContainer.clientHeight * 0.3);
}
targetScrollTop = Math.max(0, targetScrollTop);
targetScrollTop = Math.min(targetScrollTop, scrollContainer.scrollHeight - scrollContainer.clientHeight);
scrollContainer.scrollTo({ top: targetScrollTop, behavior: isSmooth ? 'smooth' : 'auto' });
const timeoutDuration = isSmooth ? 800 : 50;
await new Promise(resolve => setTimeout(resolve, timeoutDuration));
} catch (error) {
console.warn('QuickNav:', error.message, 'Proceeding with final position.');
} finally {
this.isScrollingProgrammatically = false;
}
},
async navigateToIndex(newIndex, blockPosition = 'center') {
if (newIndex < 0 || newIndex >= this.allTurns.length) return;
const oldIndex = this.currentIndex;
if (newIndex < this.allTurns.length - 1) this.isDownButtonAtEndToggle = false;
this.currentIndex = newIndex;
this.updateHighlight(oldIndex, newIndex);
this.updateCounterDisplay();
await this.scrollToTurn(newIndex, blockPosition);
this.updateScrollPercentage();
},
updateScrollPercentage() {
const floater = document.getElementById('quicknav-badge-floater');
if (!floater) return;
if (this.currentIndex < 0) {
floater.style.visibility = 'hidden';
return;
}
const currentTurn = this.allTurns[this.currentIndex];
if (!currentTurn) {
floater.style.visibility = 'hidden';
return;
}
const rect = currentTurn.getBoundingClientRect();
const turnHeight = rect.height;
const isVisible = rect.bottom > 8 && rect.top < window.innerHeight;
if (!isVisible || turnHeight <= 0) {
floater.style.visibility = 'hidden';
return;
}
floater.style.visibility = 'visible';
const floaterHeight = floater.offsetHeight || 40;
const idealTop = (window.innerHeight / 2) - (floaterHeight / 2);
const upperBound = Math.max(8, rect.top);
const lowerBound = Math.min(window.innerHeight, rect.bottom) - floaterHeight - 4;
let finalTop = idealTop;
finalTop = Math.max(finalTop, upperBound);
finalTop = Math.min(finalTop, lowerBound);
floater.style.top = `${finalTop}px`;
floater.style.left = `${rect.right - (floater.offsetWidth / 2)}px`;
const percentageBadge = document.getElementById('quicknav-badge-percentage');
if (!percentageBadge) return;
const viewportCenterY = window.innerHeight / 2;
const distanceScrolled = viewportCenterY - rect.top;
const percentage = (distanceScrolled / turnHeight) * 100;
const clampedPercentage = Math.max(0, Math.min(percentage, 100));
percentageBadge.textContent = `${Math.round(clampedPercentage)}%`;
},
synchronizeCurrentIndexFromView() {
if (this.isUnstickingFromBottom || this.isScrollingProgrammatically || this.allTurns.length === 0) {
if (this.allTurns.length === 0) {
this.updateHighlight(this.currentIndex, -1);
this.currentIndex = -1;
}
this.updateCounterDisplay();
return;
}
if (this.currentIndex > -1 && this.currentIndex < this.allTurns.length) {
const currentTurn = this.allTurns[this.currentIndex];
const rect = currentTurn.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
this.updateScrollPercentage();
return;
}
}
const focusPointY = window.innerHeight * 0.35;
let bestMatch = { index: -1, delta: Infinity };
this.allTurns.forEach((turn, index) => {
const rect = turn.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
const delta = Math.abs(rect.top - focusPointY);
if (delta < bestMatch.delta) {
bestMatch = { index, delta };
}
}
});
if (bestMatch.index !== -1 && this.currentIndex !== bestMatch.index) {
this.updateHighlight(this.currentIndex, bestMatch.index);
this.currentIndex = bestMatch.index;
this.updateCounterDisplay();
this.updateScrollPercentage();
}
},
updateHighlight(oldIndex, newIndex) {
if (oldIndex > -1 && oldIndex < this.allTurns.length) {
this.allTurns[oldIndex].classList.remove('prompt-turn-highlight', 'response-turn-highlight');
}
const floater = document.getElementById('quicknav-badge-floater');
if (!floater) return;
if (newIndex > -1 && newIndex < this.allTurns.length) {
const newTurn = this.allTurns[newIndex];
const isPrompt = this.getTurnType(newTurn) === 'user_prompt';
newTurn.classList.add(isPrompt ? 'prompt-turn-highlight' : 'response-turn-highlight');
const badgeIndex = document.getElementById('quicknav-badge-index');
const badgeClass = isPrompt ? 'prompt-badge-bg' : 'response-badge-bg';
if (badgeIndex) {
badgeIndex.textContent = newIndex + 1;
}
floater.className = badgeClass;
floater.style.visibility = 'visible';
this.updateScrollPercentage();
} else {
floater.style.visibility = 'hidden';
}
},
updateCounterDisplay() {
let currentNumSpan = document.getElementById('chat-nav-current-num');
let totalNumSpan = document.getElementById('chat-nav-total-num');
const counterContainer = document.getElementById('chat-nav-counter');
if (!currentNumSpan || !totalNumSpan || !counterContainer) {
const counter = document.getElementById('chat-nav-counter');
if (counter) {
const current = this.currentIndex > -1 ? this.currentIndex + 1 : '-';
counter.textContent = `${current} / ${this.allTurns.length}`;
}
return;
}
const current = this.currentIndex > -1 ? this.currentIndex + 1 : '-';
const total = this.allTurns.length;
currentNumSpan.textContent = current;
totalNumSpan.textContent = total;
currentNumSpan.classList.remove('chat-nav-current-grey', 'chat-nav-current-blue');
if (this.currentIndex === total - 1 && total > 0) {
currentNumSpan.classList.add('chat-nav-current-blue');
} else {
currentNumSpan.classList.add('chat-nav-current-grey');
}
},
toggleNavMenu() {
const menuContainer = document.getElementById('chat-nav-menu-container');
const counter = document.getElementById('chat-nav-counter');
if (!menuContainer || !counter) return;
const isVisible = menuContainer.style.display === 'flex';
if (isVisible) {
menuContainer.style.display = 'none';
counter.setAttribute('aria-expanded', 'false');
document.removeEventListener('click', this.closeNavMenu, true);
this.stopDynamicMenuLoading();
counter.focus();
} else {
this.populateNavMenu();
const chatContainer = document.querySelector('ms-chunk-editor');
if (chatContainer) {
const chatWidth = chatContainer.clientWidth;
const finalWidth = Math.max(300, Math.min(chatWidth, 800));
menuContainer.style.width = `${finalWidth}px`;
}
const counterRect = counter.getBoundingClientRect();
menuContainer.style.bottom = `${window.innerHeight - counterRect.top + 8}px`;
menuContainer.style.left = `${counterRect.left + (counterRect.width / 2)}px`;
menuContainer.style.transform = 'translateX(-50%)';
menuContainer.style.display = 'flex';
counter.setAttribute('aria-expanded', 'true');
menuContainer.focus();
const items = menuContainer.querySelectorAll('.chat-nav-menu-item');
const initialFocusIndex = this.currentIndex > -1 ? this.currentIndex : 0;
this.updateMenuFocus(items, initialFocusIndex, false);
requestAnimationFrame(() => {
const availableSpace = counterRect.top - 18;
menuContainer.style.maxHeight = `${availableSpace}px`;
if (this.menuFocusedIndex > -1 && this.menuFocusedIndex < items.length) {
const menuList = document.getElementById('chat-nav-menu');
const focusedItem = items[this.menuFocusedIndex];
if (menuList && focusedItem) {
menuList.scrollTop = focusedItem.offsetTop - menuList.offsetTop - (menuList.clientHeight / 2) + (focusedItem.clientHeight / 2);
}
}
});
setTimeout(() => document.addEventListener('click', this.closeNavMenu, true), 0);
}
},
closeNavMenu(e) {
const menuContainer = document.getElementById('chat-nav-menu-container');
const counter = document.getElementById('chat-nav-counter');
if (menuContainer && counter && !menuContainer.contains(e.target) && !counter.contains(e.target) && menuContainer.style.display === 'flex') {
this.toggleNavMenu();
}
},
updateMenuFocus(items, newIndex, shouldScroll = true) {
if (!items || items.length === 0 || newIndex < 0 || newIndex >= items.length) return;
if (this.menuFocusedIndex > -1 && this.menuFocusedIndex < items.length) {
items[this.menuFocusedIndex].classList.remove('menu-item-focused');
}
items[newIndex].classList.add('menu-item-focused');
if (shouldScroll) {
const menuList = document.getElementById('chat-nav-menu');
const focusedItem = items[newIndex];
if (menuList && focusedItem) {
const itemRect = focusedItem.getBoundingClientRect();
const menuRect = menuList.getBoundingClientRect();
if (itemRect.bottom > menuRect.bottom) {
menuList.scrollTop += itemRect.bottom - menuRect.bottom;
} else if (itemRect.top < menuRect.top) {
menuList.scrollTop -= menuRect.top - itemRect.top;
}
}
}
this.menuFocusedIndex = newIndex;
},
getTextFromTurn(turn, fromDOMOnly = false) {
turn.isFallbackContent = false;
if (!fromDOMOnly) {
const turnId = turn.id;
if (turnId) {
const scrollbarButton = document.getElementById(`scrollbar-item-${turnId.replace('turn-', '')}`);
if (scrollbarButton && scrollbarButton.getAttribute('aria-label')) {
const labelText = scrollbarButton.getAttribute('aria-label');
return { display: labelText, full: labelText, source: 'scrollbar' };
}
}
}
const contentContainer = turn.querySelector('.turn-content');
if (contentContainer) {
const clonedContainer = contentContainer.cloneNode(true);
clonedContainer.querySelectorAll('ms-code-block').forEach(codeBlockElement => {
const codeContent = codeBlockElement.querySelector('pre code');
if (codeContent) {
const pre = document.createElement('pre');
pre.textContent = ` ${codeContent.textContent} `;
codeBlockElement.parentNode.replaceChild(pre, codeBlockElement);
} else { codeBlockElement.remove(); }
});
clonedContainer.querySelectorAll('.author-label, .turn-separator, ms-thought-chunk').forEach(el => el.remove());
const text = clonedContainer.textContent?.trim().replace(/\s+/g, ' ');
if (text) return { display: text, full: text, source: 'dom' };
}
turn.isFallbackContent = true;
return { display: '...', full: 'Could not extract content.', source: 'fallback' };
},
populateNavMenu() {
const menuContainer = document.getElementById('chat-nav-menu-container');
if (!menuContainer) return;
while (menuContainer.firstChild) { menuContainer.removeChild(menuContainer.firstChild); }
const header = document.createElement('div');
header.className = 'chat-nav-menu-header';
const menuList = document.createElement('ul');
menuList.id = 'chat-nav-menu';
this.allTurns.forEach((turn, index) => {
let displayContent;
if (turn.cachedContent && !turn.isFallbackContent) {
displayContent = turn.cachedContent;
} else {
displayContent = this.getTextFromTurn(turn);
}
const { display, full } = displayContent;
const truncatedText = (display.length > 200) ? display.substring(0, 197) + '...' : display;
const item = document.createElement('li');
item.className = 'chat-nav-menu-item';
item.setAttribute('role', 'menuitem');
const isPrompt = this.getTurnType(turn) === 'user_prompt';
item.classList.add(isPrompt ? 'prompt-item-bg' : 'response-item-bg');
const numberSpan = document.createElement('span');
numberSpan.className = `menu-item-number ${isPrompt ? 'prompt-number-color' : 'response-number-color'}`;
numberSpan.textContent = `${index + 1}.`;
const textSpan = document.createElement('span');
textSpan.className = 'menu-item-text';
textSpan.textContent = truncatedText;
item.append(numberSpan, textSpan);
item.title = full.replace(/\s+/g, ' ');
item.addEventListener('click', () => {
this.toggleNavMenu();
this.navigateToIndex(index);
});
menuList.appendChild(item);
});
const leftContainer = document.createElement('div');
leftContainer.className = 'header-controls left';
const loadButton = document.createElement('button');
loadButton.id = 'chat-nav-load-button';
loadButton.className = 'header-button';
loadButton.textContent = 'Load All';
loadButton.title = 'Load full text for all messages';
loadButton.addEventListener('click', (e) => {
e.stopPropagation();
this.startDynamicMenuLoading();
});
const statusIndicator = document.createElement('span');
statusIndicator.id = 'chat-nav-loader-status';
leftContainer.append(loadButton, statusIndicator);
const titleElement = document.createElement('div');
titleElement.className = 'quicknav-title';
titleElement.textContent = 'QuickNav for Google AI Studio';
const rightContainer = document.createElement('div');
rightContainer.className = 'header-controls right';
const donateButton = document.createElement('a');
donateButton.href = 'https://nowpayments.io/embeds/donation-widget?api_key=0fe4e67c-64aa-4a74-b2d2-a91608b1ccc6';
donateButton.target = '_blank';
donateButton.rel = 'noopener noreferrer';
donateButton.className = 'header-button';
donateButton.textContent = 'Donate ☕';
donateButton.title = 'Support the developer';
donateButton.addEventListener('click', e => e.stopPropagation());
rightContainer.appendChild(donateButton);
header.append(leftContainer, titleElement, rightContainer);
menuContainer.appendChild(header);
menuContainer.appendChild(menuList);
},
async forceScrollToTop(scrollContainer) {
return new Promise(resolve => {
this.isScrollingProgrammatically = true;
const firstTurn = this.allTurns[0];
if (!firstTurn) {
this.isScrollingProgrammatically = false;
return resolve();
}
let attempts = 0;
const maxAttempts = 15;
const attemptScroll = () => {
attempts++;
scrollContainer.scrollTop = 0;
setTimeout(() => {
if (scrollContainer.scrollTop < 50 || attempts >= maxAttempts) {
firstTurn.scrollIntoView({ behavior: 'auto', block: 'start' });
setTimeout(() => { this.isScrollingProgrammatically = false; resolve(); }, 100);
} else {
attemptScroll();
}
}, 150);
};
attemptScroll();
});
},
async startDynamicMenuLoading() {
if (this.isQueueProcessing) return;
const loadButton = document.getElementById('chat-nav-load-button');
const scrollContainer = document.querySelector('ms-autoscroll-container');
if (!scrollContainer || !loadButton) return;
this.originalCurrentIndex = this.currentIndex;
this.originalScrollTop = scrollContainer.scrollTop;
const menuItems = document.querySelectorAll('#chat-nav-menu .chat-nav-menu-item');
this.loadingQueue = this.allTurns
.map((turn, index) => ({ turn, index, menuItem: menuItems[index] }))
.filter(item => {
const text = item.menuItem.title;
return text.endsWith('...') || text === 'Could not extract content.' || !item.turn.cachedContent || item.turn.isFallbackContent;
});
if (this.loadingQueue.length > 0) {
this.totalToLoad = this.loadingQueue.length;
loadButton.disabled = true;
this.isQueueProcessing = true;
await this.forceScrollToTop(scrollContainer);
this.processLoadingQueue();
} else {
const statusIndicator = document.getElementById('chat-nav-loader-status');
if (statusIndicator) statusIndicator.textContent = 'All loaded.';
}
},
pollForContent(turn) {
return new Promise((resolve, reject) => {
const maxAttempts = 50;
let attempts = 0;
const interval = setInterval(() => {
if (!this.isQueueProcessing) {
clearInterval(interval);
return reject(new Error('Loading stopped by user.'));
}
const content = this.getTextFromTurn(turn, true);
if (content.source === 'dom') {
clearInterval(interval);
resolve(content);
} else if (++attempts >= maxAttempts) {
clearInterval(interval);
reject(new Error('Content polling timed out.'));
}
}, 100);
});
},
async processLoadingQueue() {
const statusIndicator = document.getElementById('chat-nav-loader-status');
const menuItems = document.querySelectorAll('#chat-nav-menu .chat-nav-menu-item');
while (this.loadingQueue.length > 0 && this.isQueueProcessing) {
const itemsProcessed = this.totalToLoad - this.loadingQueue.length;
if (statusIndicator) statusIndicator.textContent = `Loading ${itemsProcessed + 1} of ${this.totalToLoad}...`;
const itemToLoad = this.loadingQueue.shift();
const { turn, index, menuItem } = itemToLoad;
const textSpan = menuItem.querySelector('.menu-item-text');
if (!turn || !textSpan) continue;
this.updateMenuFocus(menuItems, index, true);
try {
await this.scrollToTurn(index, 'center');
const newContent = await this.pollForContent(turn);
turn.cachedContent = newContent;
turn.isFallbackContent = false;
const truncatedText = (newContent.display.length > 200) ? newContent.display.substring(0, 197) + '...' : newContent.display;
textSpan.textContent = truncatedText;
menuItem.title = newContent.full.replace(/\s+/g, ' ');
} catch (error) {
console.error(`Failed to load item ${index + 1}:`, error.message);
textSpan.textContent = '[Error]';
}
}
this.isQueueProcessing = false;
const scrollContainer = document.querySelector('ms-autoscroll-container');
if (this.originalCurrentIndex > -1 && this.originalCurrentIndex < this.allTurns.length) {
await this.navigateToIndex(this.originalCurrentIndex, 'center');
} else if (scrollContainer) {
this.isScrollingProgrammatically = true;
scrollContainer.scrollTo({ top: this.originalScrollTop, behavior: 'smooth' });
await new Promise(resolve => setTimeout(() => { this.isScrollingProgrammatically = false; resolve(); }, 800));
}
const menuContainer = document.getElementById('chat-nav-menu-container');
const items = menuContainer ? menuContainer.querySelectorAll('.chat-nav-menu-item') : [];
if (menuContainer && menuContainer.style.display === 'flex' && this.currentIndex > -1 && items.length > 0) {
this.updateMenuFocus(items, this.currentIndex, true);
}
const loadButton = document.getElementById('chat-nav-load-button');
if (loadButton) loadButton.disabled = false;
if (statusIndicator) statusIndicator.textContent = this.loadingQueue.length > 0 ? 'Stopped.' : 'Done.';
},
stopDynamicMenuLoading() {
if (!this.isQueueProcessing) return;
this.isQueueProcessing = false;
const loadButton = document.getElementById('chat-nav-load-button');
if (loadButton) loadButton.disabled = false;
const statusIndicator = document.getElementById('chat-nav-loader-status');
if (statusIndicator) statusIndicator.textContent = 'Stopped.';
}
};
ChatNavigator.closeNavMenu = ChatNavigator.closeNavMenu.bind(ChatNavigator);
ChatNavigator.init();
})();