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.
// ==UserScript==
// @name QuickNav for Google AI Studio
// @namespace http://tampermonkey.net/
// @version 20.4
// @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/donation/axl_script
// @match https://aistudio.google.com/*
// @grant GM_setValue
// @grant GM_getValue
// @license MIT
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
// Manages chat state, scrolling, observers, and turn indexing.
const ChatController = {
allTurns: [],
currentIndex: -1,
menuFocusedIndex: -1,
isDownButtonAtEndToggle: false,
JUMP_DISTANCE: 5,
isScrollingProgrammatically: false,
isQueueProcessing: false,
isUnstickingFromBottom: false,
originalScrollTop: 0,
originalCurrentIndex: -1,
loadingQueue: [],
totalToLoad: 0,
chatObserver: null,
visibilityObserver: null,
scrollObserver: null,
chatContainerElement: null,
currentScrollContainer: null,
holdTimeout: null,
holdInterval: null,
isBottomHoldActive: false,
bottomHoldTimeout: null,
bottomHoldInterval: null,
debouncedBuildTurnIndex: null,
visibleTurnContainers: new Set(),
isScrollUpdateQueued: false,
boundHandlePageScroll: null,
boundHandleRealtimeScrollSync: null,
isScrollLocked: false,
scrollLockInterval: null,
scrollLockTimeout: null,
init(chatContainer) {
this.chatContainerElement = chatContainer;
this.debouncedBuildTurnIndex = this.debounce(this.buildTurnIndex.bind(this), 750);
this.boundHandlePageScroll = UIManager.handlePageScroll.bind(UIManager);
this.boundHandleRealtimeScrollSync = this.handleRealtimeScrollSync.bind(this);
this.initializeChatObserver(this.chatContainerElement);
this.attachScrollListeners();
this.buildTurnIndex();
document.addEventListener('mousedown', this.stopBottomHold.bind(this), true);
document.addEventListener('wheel', this.stopBottomHold.bind(this), true);
document.addEventListener('click', this.handleEditClick.bind(this), true);
},
attachScrollListeners() {
const newScrollContainer = this.chatContainerElement?.querySelector('ms-autoscroll-container');
if (this.currentScrollContainer === newScrollContainer && newScrollContainer?.isConnected) {
return;
}
if (this.currentScrollContainer) {
this.currentScrollContainer.removeEventListener('scroll', this.boundHandlePageScroll);
this.currentScrollContainer.removeEventListener('scroll', this.boundHandleRealtimeScrollSync);
}
if (newScrollContainer) {
this.currentScrollContainer = newScrollContainer;
this.currentScrollContainer.addEventListener('scroll', this.boundHandlePageScroll);
this.currentScrollContainer.addEventListener('scroll', this.boundHandleRealtimeScrollSync);
this.handleRealtimeScrollSync();
} else {
this.currentScrollContainer = null;
}
},
destroy() {
if (this.chatObserver) this.chatObserver.disconnect();
if (this.visibilityObserver) this.visibilityObserver.disconnect();
if (this.scrollObserver) this.scrollObserver.disconnect();
this.chatObserver = null;
this.visibilityObserver = null;
this.scrollObserver = null;
this.clearScrollLock();
if (this.currentScrollContainer) {
this.currentScrollContainer.removeEventListener('scroll', this.boundHandlePageScroll);
this.currentScrollContainer.removeEventListener('scroll', this.boundHandleRealtimeScrollSync);
}
this.currentScrollContainer = null;
this.chatContainerElement = null;
this.allTurns = [];
this.currentIndex = -1;
this.menuFocusedIndex = -1;
document.removeEventListener('mousedown', this.stopBottomHold.bind(this), true);
document.removeEventListener('wheel', this.stopBottomHold.bind(this), true);
document.removeEventListener('click', this.handleEditClick.bind(this), true);
},
clearScrollLock() {
clearInterval(this.scrollLockInterval);
clearTimeout(this.scrollLockTimeout);
this.scrollLockInterval = null;
this.scrollLockTimeout = null;
this.isScrollLocked = false;
},
handleEditClick(event) {
const editButton = event.target.closest('ms-chat-turn .toggle-edit-button');
if (!editButton || this.isScrollLocked) return;
const turnElement = event.target.closest('ms-chat-turn');
const scrollContainer = this.currentScrollContainer;
if (!turnElement || !scrollContainer) return;
const buttonIcon = editButton.querySelector('.ms-button-icon-symbol');
const isEnteringEditMode = buttonIcon && buttonIcon.textContent.trim() !== 'done_all';
if (!isEnteringEditMode) return;
const savedScrollTop = scrollContainer.scrollTop;
this.isScrollLocked = true;
const immediateClearLock = () => {
this.clearScrollLock();
scrollContainer.removeEventListener('wheel', immediateClearLock, { once: true });
scrollContainer.removeEventListener('mousedown', immediateClearLock, { once: true });
};
scrollContainer.addEventListener('wheel', immediateClearLock, { once: true });
scrollContainer.addEventListener('mousedown', immediateClearLock, { once: true });
this.scrollLockInterval = setInterval(() => {
if (scrollContainer.scrollTop !== savedScrollTop) {
scrollContainer.scrollTop = savedScrollTop;
}
}, 15);
this.scrollLockTimeout = setTimeout(() => {
immediateClearLock();
}, 5000);
},
debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
},
// Sets up the MutationObserver to track chat additions and removals.
initializeChatObserver(container) {
const observerCallback = (mutationsList) => {
this.attachScrollListeners();
let shouldRebuildIndex = false;
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
if (mutation.addedNodes.length > 0) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) {
if (node.matches('ms-chat-turn') || node.querySelector('ms-chat-turn')) {
shouldRebuildIndex = true;
}
const codeBlock = node.matches('ms-code-block') ? node : node.querySelector('ms-code-block');
if (codeBlock) {
const parentTurn = codeBlock.closest('ms-chat-turn');
if (parentTurn) CodeBlockNavigator.processTurns([parentTurn]);
}
}
}
}
if (mutation.removedNodes.length > 0) {
const turnRemoved = Array.from(mutation.removedNodes).some(node =>
node.nodeType === 1 && (
node.matches('ms-chat-turn') ||
node.querySelector?.('ms-chat-turn')
)
);
if (turnRemoved) {
shouldRebuildIndex = true;
}
}
}
}
if (shouldRebuildIndex) {
this.debouncedBuildTurnIndex();
}
};
this.chatObserver = new MutationObserver(observerCallback);
this.chatObserver.observe(container, { childList: true, subtree: true });
},
setupVisibilityObserver() {
if (this.visibilityObserver) {
this.visibilityObserver.disconnect();
}
const scrollContainer = this.currentScrollContainer || document.querySelector('ms-autoscroll-container');
if (!scrollContainer || this.allTurns.length === 0) {
return;
}
const observerCallback = (entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const visibleTurnElement = entry.target;
const turnIndex = this.allTurns.findIndex(t => t === visibleTurnElement);
if (turnIndex === -1) continue;
CodeBlockNavigator.processTurns([visibleTurnElement]);
const turnObject = this.allTurns[turnIndex];
const newContent = UIManager.getTextFromTurn(visibleTurnElement, true);
if (newContent.source === 'fallback') {
continue;
}
const hasValidCache = turnObject.cachedContent && !turnObject.isFallbackContent;
const isAnUpgrade = hasValidCache && turnObject.cachedContent.source === 'scrollbar' && newContent.source === 'dom';
const contentContainer = turnObject.querySelector('.turn-content');
const contentHasChanged = hasValidCache && Math.abs(turnObject.cachedContent.full.length - newContent.full.length) > 5;
if (!hasValidCache || isAnUpgrade || contentHasChanged) {
turnObject.cachedContent = newContent;
turnObject.isFallbackContent = false;
UIManager.updateMenuItemContent(turnIndex);
}
}
}
};
this.visibilityObserver = new IntersectionObserver(observerCallback, {
root: scrollContainer,
rootMargin: "0px",
});
this.allTurns.forEach(turn => this.visibilityObserver.observe(turn));
},
setupScrollObserver() {
if (this.scrollObserver) this.scrollObserver.disconnect();
const scrollContainer = this.currentScrollContainer || document.querySelector('ms-autoscroll-container');
if (!scrollContainer || this.allTurns.length === 0) return;
const observerCallback = (entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.visibleTurnContainers.add(entry.target);
} else {
this.visibleTurnContainers.delete(entry.target);
}
});
};
this.scrollObserver = new IntersectionObserver(observerCallback, {
root: scrollContainer,
rootMargin: "0px",
threshold: 0
});
this.allTurns.forEach(turn => {
const container = turn.querySelector('.chat-turn-container');
if (container) {
this.scrollObserver.observe(container);
}
});
},
handleRealtimeScrollSync() {
if (this.isScrollUpdateQueued) {
return;
}
this.isScrollUpdateQueued = true;
requestAnimationFrame(() => {
if (this.isScrollingProgrammatically || this.isQueueProcessing || this.isUnstickingFromBottom || this.isScrollLocked) {
this.isScrollUpdateQueued = false;
return;
}
if (!this.currentScrollContainer || !this.currentScrollContainer.isConnected) {
this.attachScrollListeners();
}
const scrollContainer = this.currentScrollContainer;
if (!scrollContainer) {
this.isScrollUpdateQueued = false;
return;
}
const activationLine = scrollContainer.clientHeight * 0.3;
let bestMatch = { element: null, minDistance: Infinity };
const containersToCheck = this.visibleTurnContainers.size > 0
? this.visibleTurnContainers
: this.allTurns.map(t => t.querySelector('.chat-turn-container')).filter(Boolean);
containersToCheck.forEach(containerElement => {
const rect = containerElement.getBoundingClientRect();
const distance = Math.abs(rect.top - activationLine);
if (distance < bestMatch.minDistance) {
bestMatch = { element: containerElement, minDistance: distance };
}
});
if (bestMatch.element) {
const parentTurn = bestMatch.element.closest('ms-chat-turn');
if (parentTurn) {
const bestMatchIndex = this.allTurns.indexOf(parentTurn);
if (bestMatchIndex !== -1 && this.currentIndex !== bestMatchIndex) {
UIManager.updateHighlight(this.currentIndex, bestMatchIndex);
this.currentIndex = bestMatchIndex;
const currentTurn = this.allTurns[bestMatchIndex];
if (currentTurn && (!currentTurn.cachedContent || currentTurn.isFallbackContent)) {
const newContent = UIManager.getTextFromTurn(currentTurn, true);
if (newContent.source !== 'fallback') {
currentTurn.cachedContent = newContent;
currentTurn.isFallbackContent = false;
}
}
UIManager.updateMenuItemContent(bestMatchIndex);
UIManager.updateCounterDisplay();
UIManager.showBadge();
UIManager.updateScrollPercentage();
UIManager.hideBadge();
}
}
}
this.isScrollUpdateQueued = false;
});
},
isUserPrompt(turnElement) {
return !!turnElement.querySelector('.chat-turn-container.user');
},
// Scans the DOM to build the index of chat turns.
buildTurnIndex() {
const freshTurns = Array.from(document.querySelectorAll('ms-chat-turn')).filter(turn => {
const isUser = !!turn.querySelector('.chat-turn-container.user');
if (isUser) return true;
const isModel = !!turn.querySelector('.chat-turn-container.model');
if (isModel) {
const isThought = !!turn.querySelector('ms-thought-chunk');
const hasEditButton = !!turn.querySelector('.toggle-edit-button');
const contentContainer = turn.querySelector('.turn-content');
const hasRenderedContent = !!(contentContainer && contentContainer.querySelector('ms-prompt-chunk, ms-code-block'));
return !isThought && (hasEditButton || hasRenderedContent);
}
return false;
});
if (freshTurns.length !== this.allTurns.length || !this.arraysEqual(this.allTurns, freshTurns)) {
const oldIndex = this.currentIndex;
const freshTurnsSet = new Set(freshTurns);
this.allTurns.forEach(oldTurn => {
if (!freshTurnsSet.has(oldTurn)) {
const container = oldTurn.querySelector('.chat-turn-container');
if (container) {
container.classList.remove('prompt-turn-highlight', 'response-turn-highlight');
}
}
});
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;
}
});
if (this.allTurns.length === 0) {
this.currentIndex = -1;
} else if (this.currentIndex >= this.allTurns.length) {
this.currentIndex = this.allTurns.length - 1;
UIManager.updateHighlight(-1, this.currentIndex);
} else {
UIManager.updateHighlight(-1, this.currentIndex);
}
this.setupScrollObserver();
this.setupVisibilityObserver();
const menuContainer = document.getElementById('chat-nav-menu-container');
if (menuContainer && menuContainer.classList.contains('visible')) {
UIManager.populateNavMenu();
const menuList = document.getElementById('chat-nav-menu');
const items = menuList ? menuList.querySelectorAll('.chat-nav-menu-item') : [];
if (items.length > 0) {
const safeFocusIndex = Math.min(this.menuFocusedIndex, items.length - 1);
UIManager.updateMenuFocus(items, Math.max(0, safeFocusIndex), false);
}
}
if (this.allTurns.length > 0 && this.currentIndex === -1) {
setTimeout(() => this.handleRealtimeScrollSync(), 150);
}
}
CodeBlockNavigator.processTurns(this.allTurns);
UIManager.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;
},
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);
},
scrollToAbsoluteTop() {
const scrollContainer = this.currentScrollContainer;
if (!scrollContainer) return;
UIManager.hideBadge(0, true);
this.isScrollingProgrammatically = true;
scrollContainer.scrollTo({
top: 0,
behavior: this.holdInterval ? 'auto' : 'smooth'
});
if (this.allTurns.length > 0 && this.currentIndex !== 0) {
UIManager.updateHighlight(this.currentIndex, 0);
this.currentIndex = 0;
UIManager.updateCounterDisplay();
}
setTimeout(() => {
this.isScrollingProgrammatically = false;
UIManager.showBadge();
UIManager.updateScrollPercentage();
UIManager.hideBadge(2000);
this.focusChatContainer();
}, 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 = this.currentScrollContainer;
if (!scrollContainer) {
this.isScrollingProgrammatically = false;
return;
}
try {
const verticalOffset = scrollContainer.clientHeight * 0.35;
let initialTargetScrollTop;
if (blockPosition === 'end') {
initialTargetScrollTop = targetTurn.offsetTop - (scrollContainer.clientHeight - targetTurn.offsetHeight);
} else {
initialTargetScrollTop = targetTurn.offsetTop - verticalOffset;
}
scrollContainer.scrollTop = Math.max(0, Math.min(initialTargetScrollTop, scrollContainer.scrollHeight - scrollContainer.clientHeight));
await this.waitForTurnToStabilize(targetTurn, 4000);
let finalTargetScrollTop;
if (blockPosition === 'end') {
finalTargetScrollTop = targetTurn.offsetTop - (scrollContainer.clientHeight - targetTurn.offsetHeight);
} else {
finalTargetScrollTop = targetTurn.offsetTop - verticalOffset;
}
finalTargetScrollTop = Math.max(0, finalTargetScrollTop);
finalTargetScrollTop = Math.min(finalTargetScrollTop, scrollContainer.scrollHeight - scrollContainer.clientHeight);
scrollContainer.scrollTo({
top: finalTargetScrollTop,
behavior: isSmooth ? 'smooth' : 'auto'
});
const timeoutDuration = isSmooth ? 800 : 50;
await new Promise(resolve => setTimeout(resolve, timeoutDuration));
} catch (error) {} finally {
this.isScrollingProgrammatically = false;
UIManager.showBadge();
UIManager.updateScrollPercentage();
UIManager.hideBadge(2000);
}
},
async navigateToIndex(newIndex, blockPosition = 'center') {
if (newIndex < 0 || newIndex >= this.allTurns.length) return;
UIManager.hideBadge(0, true);
const oldIndex = this.currentIndex;
if (newIndex < this.allTurns.length - 1) this.isDownButtonAtEndToggle = false;
this.currentIndex = newIndex;
UIManager.updateHighlight(oldIndex, newIndex);
UIManager.updateCounterDisplay();
await this.scrollToTurn(newIndex, blockPosition);
const targetTurn = this.allTurns[newIndex];
if (targetTurn) {
const newContent = UIManager.getTextFromTurn(targetTurn, true);
if (newContent.source !== 'fallback') {
targetTurn.cachedContent = newContent;
targetTurn.isFallbackContent = false;
UIManager.updateMenuItemContent(newIndex);
}
}
UIManager.updateScrollPercentage();
this.focusChatContainer();
},
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 = this.currentScrollContainer;
if (!scrollContainer || !loadButton) return;
const menuList = document.getElementById('chat-nav-menu');
const menuItems = menuList ? menuList.querySelectorAll('.chat-nav-menu-item') : [];
if (menuList && menuItems.length > 0) {
const targetIndex = this.currentIndex > -1 ? this.currentIndex : 0;
UIManager.updateMenuFocus(menuItems, targetIndex, true);
menuList.focus({ preventScroll: true });
}
this.originalCurrentIndex = this.currentIndex;
this.originalScrollTop = scrollContainer.scrollTop;
this.loadingQueue = this.allTurns
.map((turn, index) => ({ turn, index, menuItem: menuItems[index] }))
.filter(item => {
const text = item.menuItem.dataset.tooltip;
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;
loadButton.classList.add('loading-active');
this.isQueueProcessing = true;
const isAtBottom = scrollContainer.scrollTop >= scrollContainer.scrollHeight - scrollContainer.clientHeight - 5;
if (isAtBottom) {
this.isUnstickingFromBottom = true;
scrollContainer.scrollTop -= 10;
await new Promise(resolve => setTimeout(resolve, 50));
this.isUnstickingFromBottom = false;
}
await this.forceScrollToTop(scrollContainer);
this.processLoadingQueue();
} else {
const statusIndicator = document.getElementById('chat-nav-loader-status');
if (statusIndicator) {
statusIndicator.textContent = 'All loaded.';
statusIndicator.classList.add('google-text-flash');
statusIndicator.addEventListener('animationend', () => {
statusIndicator.classList.remove('google-text-flash');
}, { once: true });
}
}
},
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 = UIManager.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}...`;
statusIndicator.classList.remove('google-text-flash');
}
const itemToLoad = this.loadingQueue.shift();
const { turn, index, menuItem } = itemToLoad;
const textSpan = menuItem.querySelector('.menu-item-text');
if (!turn || !textSpan) continue;
menuItem.classList.add('loading-in-progress');
UIManager.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.dataset.tooltip = newContent.full.replace(/\s+/g, ' ');
} catch (error) {
console.error(`Failed to load item ${index + 1}:`, error.message);
textSpan.textContent = '[Error]';
} finally {
menuItem.classList.remove('loading-in-progress');
}
}
this.isQueueProcessing = false;
const scrollContainer = this.currentScrollContainer;
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 loadButton = document.getElementById('chat-nav-load-button');
if (loadButton) {
loadButton.disabled = false;
loadButton.classList.remove('loading-active');
}
if (statusIndicator) {
if (this.loadingQueue.length > 0) {
statusIndicator.textContent = 'Stopped.';
statusIndicator.classList.remove('google-text-flash');
} else {
statusIndicator.textContent = 'Done.';
statusIndicator.classList.add('google-text-flash');
statusIndicator.addEventListener('animationend', () => {
statusIndicator.classList.remove('google-text-flash');
}, { once: true });
}
}
if (menuContainer && menuContainer.classList.contains('visible')) {
const items = menuContainer.querySelectorAll('.chat-nav-menu-item');
UIManager.updateMenuFocus(items, this.currentIndex, true);
const menuList = document.getElementById('chat-nav-menu');
if (menuList) {
menuList.focus({ preventScroll: true });
}
} else {
this.focusChatContainer();
}
},
stopDynamicMenuLoading() {
if (!this.isQueueProcessing) return;
this.isQueueProcessing = false;
const loadButton = document.getElementById('chat-nav-load-button');
if (loadButton) {
loadButton.disabled = false;
loadButton.classList.remove('loading-active');
}
const statusIndicator = document.getElementById('chat-nav-loader-status');
if (statusIndicator) {
statusIndicator.textContent = 'Stopped.';
statusIndicator.classList.remove('google-text-animated');
}
},
stopBottomHold() {
if (!this.isBottomHoldActive) return;
this.isBottomHoldActive = false;
clearInterval(this.bottomHoldInterval);
this.bottomHoldInterval = null;
const btnBottom = document.getElementById('nav-bottom');
if (btnBottom) {
btnBottom.classList.remove('auto-click-active');
}
},
focusChatContainer() {
const scrollContainer = this.currentScrollContainer;
if (scrollContainer) {
scrollContainer.tabIndex = -1;
scrollContainer.focus({ preventScroll: true });
}
},
// Focuses the main chat input field with visual feedback.
focusPromptInput() {
const targetElement = document.querySelector('ms-prompt-box textarea') || document.querySelector('textarea[placeholder="Start typing a prompt"]');
if (!targetElement) {
console.error("QuickNav: Prompt input textarea not found.");
return;
}
const originalTransition = targetElement.style.transition;
const originalShadow = targetElement.style.boxShadow;
targetElement.style.transition = 'box-shadow 0.2s ease-out';
targetElement.style.boxShadow = '0 0 0 2px var(--ms-primary, #8ab4f8)';
setTimeout(() => {
targetElement.style.boxShadow = originalShadow;
setTimeout(() => {
targetElement.style.transition = originalTransition;
}, 200);
}, 400);
const mouseDownEvent = new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window });
const mouseUpEvent = new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window });
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true, view: window });
targetElement.dispatchEvent(mouseDownEvent);
targetElement.dispatchEvent(mouseUpEvent);
targetElement.dispatchEvent(clickEvent);
targetElement.focus();
}
};
// Manages global visual settings: Font Size, Chat Width, and Hover Delay.
const StyleManager = {
FONT: {
DEFAULT: 14,
MIN: 10,
MAX: 32,
STEP: 1,
current: 14,
enabled: true,
storageKey: 'quicknav_font_size',
enableKey: 'quicknav_font_enabled'
},
WIDTH: {
DEFAULT: 1000,
MIN: 600,
MAX: 8000,
STEP: 10,
current: 1000,
enabled: false,
storageKey: 'quicknav_chat_width',
enableKey: 'quicknav_width_enabled'
},
HOVER_MENU: {
enabled: false,
storageKey: 'quicknav_hover_menu_enabled',
enableKey: 'quicknav_hover_menu_enabled'
},
HOVER_DELAY: {
DEFAULT: 250,
MIN: 0,
MAX: 5000,
STEP: 50,
current: 250,
enabled: true,
storageKey: 'quicknav_hover_delay'
},
styleElement: null,
widthResizeObserver: null,
observedElement: null,
ui: {
fontIndicator: null,
fontControls: null,
fontBtnMinus: null,
fontBtnPlus: null,
fontCheckbox: null,
widthIndicator: null,
widthControls: null,
widthBtnMinus: null,
widthBtnPlus: null,
widthCheckbox: null,
hoverCheckbox: null,
hoverControls: null,
hoverIndicator: null,
hoverBtnMinus: null,
hoverBtnPlus: null
},
// Init method with Smart Resize Listener.
init() {
this.loadSettings();
this.injectStaticBaseStyles();
setTimeout(() => {
this.setupWidthObserver();
this.applyVariableUpdates();
}, 500);
let resizeTimeout;
window.addEventListener('resize', () => {
const dropdown = document.getElementById('quicknav-settings-dropdown');
if (!dropdown || !dropdown.classList.contains('visible')) return;
if (!resizeTimeout) {
resizeTimeout = setTimeout(() => {
resizeTimeout = null;
this.updateUIDisplay();
}, 150);
}
});
},
setupWidthObserver() {
const target = document.querySelector('.chat-session-content') || document.querySelector('ms-chunk-editor');
if (target && target !== this.observedElement) {
if (this.widthResizeObserver) this.widthResizeObserver.disconnect();
this.observedElement = target;
this.widthResizeObserver = new ResizeObserver((entries) => {
if (!this.WIDTH.enabled) {
for (const entry of entries) {
if (entry.contentBoxSize) {
const width = entry.contentRect.width;
document.documentElement.style.setProperty('--quicknav-chat-width', `${width}px`);
}
}
}
});
this.widthResizeObserver.observe(target);
}
},
measureAndSyncWidth() {
if (this.observedElement) {
const rect = this.observedElement.getBoundingClientRect();
document.documentElement.style.setProperty('--quicknav-chat-width', `${rect.width}px`);
}
},
// --- ROBUST STORAGE SYSTEM ---
_read(key, defaultValue) {
let value;
try {
if (typeof GM_getValue === 'function') {
value = GM_getValue(key);
}
} catch (e) {
console.warn(`[QuickNav] GM_getValue failed for ${key}, trying localStorage.`);
}
if (value === undefined) {
try {
const local = localStorage.getItem(key);
if (local !== null) {
if (local === 'true') value = true;
else if (local === 'false') value = false;
else if (!isNaN(Number(local))) value = Number(local);
else value = local;
}
} catch (e) {
console.error(`[QuickNav] localStorage failed for ${key}:`, e);
}
}
return value !== undefined ? value : defaultValue;
},
_write(key, value) {
try {
if (typeof GM_setValue === 'function') {
GM_setValue(key, value);
}
} catch (e) {
console.warn(`[QuickNav] GM_setValue failed for ${key}, falling back.`);
}
try {
localStorage.setItem(key, String(value));
} catch (e) {
console.error(`[QuickNav] localStorage write failed:`, e);
}
},
// Loads settings from storage.
loadSettings() {
const savedFont = this._read(this.FONT.storageKey, this.FONT.DEFAULT);
this.FONT.current = parseInt(savedFont, 10) || this.FONT.DEFAULT;
const savedFontEnabled = this._read(this.FONT.enableKey, true);
this.FONT.enabled = !!savedFontEnabled;
const savedWidth = this._read(this.WIDTH.storageKey, this.WIDTH.DEFAULT);
this.WIDTH.current = parseInt(savedWidth, 10) || this.WIDTH.DEFAULT;
const savedWidthEnabled = this._read(this.WIDTH.enableKey, false);
this.WIDTH.enabled = !!savedWidthEnabled;
const savedHover = this._read(this.HOVER_MENU.storageKey, false);
this.HOVER_MENU.enabled = !!savedHover;
const savedHoverDelay = this._read(this.HOVER_DELAY.storageKey, this.HOVER_DELAY.DEFAULT);
this.HOVER_DELAY.current = parseInt(savedHoverDelay, 10);
if (isNaN(this.HOVER_DELAY.current)) this.HOVER_DELAY.current = this.HOVER_DELAY.DEFAULT;
},
// Injects static CSS rules utilizing CSS Variables.
injectStaticBaseStyles() {
if (document.getElementById('quicknav-font-styles')) return;
this.styleElement = document.createElement('style');
this.styleElement.id = 'quicknav-font-styles';
this.styleElement.textContent = `
:root {
--quicknav-font-size: ${this.FONT.DEFAULT}px;
--quicknav-chat-width: ${this.WIDTH.DEFAULT}px;
}
/* Apply font size to chat content and prompt box */
html.quicknav-font-active .turn-content,
html.quicknav-font-active .turn-content p,
html.quicknav-font-active .turn-content li,
html.quicknav-font-active .turn-content span,
html.quicknav-font-active .turn-content .cmark-node,
html.quicknav-font-active .turn-content pre,
html.quicknav-font-active .turn-content code,
html.quicknav-font-active .turn-content textarea,
html.quicknav-font-active .prompt-box-container textarea,
html.quicknav-font-active .prompt-input-wrapper-container textarea,
html.quicknav-font-active ms-prompt-input-wrapper textarea {
font-size: var(--quicknav-font-size) !important;
line-height: 1.6 !important;
}
/* Apply font size to Navigation Menu and Tooltips */
html.quicknav-font-active .chat-nav-menu-item,
html.quicknav-font-active #quicknav-custom-tooltip {
font-size: var(--quicknav-font-size) !important;
}
html.quicknav-width-active .chat-session-content,
html.quicknav-width-active .prompt-box-container,
html.quicknav-width-active ms-prompt-box,
html.quicknav-width-active .prompt-input-wrapper-container {
max-width: var(--quicknav-chat-width) !important;
}
`;
document.head.appendChild(this.styleElement);
},
applyVariableUpdates() {
const root = document.documentElement;
if (this.FONT.enabled) {
root.style.setProperty('--quicknav-font-size', `${this.FONT.current}px`);
root.classList.add('quicknav-font-active');
} else {
root.classList.remove('quicknav-font-active');
}
if (this.WIDTH.enabled) {
root.style.setProperty('--quicknav-chat-width', `${this.WIDTH.current}px`);
root.classList.add('quicknav-width-active');
} else {
root.classList.remove('quicknav-width-active');
this.measureAndSyncWidth();
}
this.updateUIDisplay();
},
applyStyles() {
this.applyVariableUpdates();
},
getSafeWidthMax() {
const scrollContainer = document.querySelector('ms-autoscroll-container') || document.body;
return Math.floor(scrollContainer.clientWidth - 24);
},
updateUIDisplay() {
if (this.ui.fontIndicator) {
this.ui.fontIndicator.textContent = `${this.FONT.current}`;
this.updateIndicatorStyle(this.ui.fontIndicator, this.FONT);
}
if (this.ui.fontCheckbox) {
this.ui.fontCheckbox.checked = this.FONT.enabled;
}
if (this.ui.fontControls) {
if (this.FONT.enabled) {
this.ui.fontControls.classList.remove('disabled');
if (this.ui.fontBtnMinus) this.ui.fontBtnMinus.disabled = (this.FONT.current <= this.FONT.MIN);
if (this.ui.fontBtnPlus) this.ui.fontBtnPlus.disabled = (this.FONT.current >= this.FONT.MAX);
const btnReset = this.ui.fontControls.querySelector('.quicknav-tool-indicator');
if (btnReset) btnReset.disabled = false;
} else {
this.ui.fontControls.classList.add('disabled');
this.ui.fontControls.querySelectorAll('button').forEach(btn => btn.disabled = true);
}
}
if (this.ui.widthIndicator) {
this.ui.widthIndicator.textContent = `${this.WIDTH.current}`;
this.updateIndicatorStyle(this.ui.widthIndicator, this.WIDTH);
}
if (this.ui.widthCheckbox) {
this.ui.widthCheckbox.checked = this.WIDTH.enabled;
}
if (this.ui.widthControls) {
if (this.WIDTH.enabled) {
this.ui.widthControls.classList.remove('disabled');
const safeMax = this.getSafeWidthMax();
if (this.ui.widthBtnMinus) this.ui.widthBtnMinus.disabled = (this.WIDTH.current <= this.WIDTH.MIN);
if (this.ui.widthBtnPlus) {
this.ui.widthBtnPlus.disabled = (this.WIDTH.current >= this.WIDTH.MAX || this.WIDTH.current >= safeMax);
}
const btnReset = this.ui.widthControls.querySelector('.quicknav-tool-indicator');
if (btnReset) btnReset.disabled = false;
} else {
this.ui.widthControls.classList.add('disabled');
this.ui.widthControls.querySelectorAll('button').forEach(btn => btn.disabled = true);
}
}
if (this.ui.hoverCheckbox) {
this.ui.hoverCheckbox.checked = this.HOVER_MENU.enabled;
}
if (this.ui.hoverIndicator) {
this.ui.hoverIndicator.textContent = `${this.HOVER_DELAY.current}`;
this.ui.hoverIndicator.title = `Reset delay to ${this.HOVER_DELAY.DEFAULT}ms`;
const proxyConfig = {
current: this.HOVER_DELAY.current,
DEFAULT: this.HOVER_DELAY.DEFAULT,
enabled: this.HOVER_MENU.enabled
};
this.updateIndicatorStyle(this.ui.hoverIndicator, proxyConfig);
}
if (this.ui.hoverControls) {
if (this.HOVER_MENU.enabled) {
this.ui.hoverControls.classList.remove('disabled');
if (this.ui.hoverBtnMinus) this.ui.hoverBtnMinus.disabled = (this.HOVER_DELAY.current <= this.HOVER_DELAY.MIN);
if (this.ui.hoverBtnPlus) this.ui.hoverBtnPlus.disabled = (this.HOVER_DELAY.current >= this.HOVER_DELAY.MAX);
const btnReset = this.ui.hoverControls.querySelector('.quicknav-tool-indicator');
if (btnReset) btnReset.disabled = false;
} else {
this.ui.hoverControls.classList.add('disabled');
this.ui.hoverControls.querySelectorAll('button').forEach(btn => btn.disabled = true);
}
}
},
updateIndicatorStyle(element, config) {
if (config.current !== config.DEFAULT && config.enabled) {
element.style.fontWeight = '500';
element.style.backgroundColor = 'rgba(138, 180, 248, 0.1)';
} else {
element.style.fontWeight = '500';
element.style.backgroundColor = 'transparent';
}
},
toggleSetting(type, state) {
if (type === 'HOVER_MENU') {
this.HOVER_MENU.enabled = state;
this._write(this.HOVER_MENU.enableKey, state);
this.updateUIDisplay();
return;
}
this[type].enabled = state;
this._write(this[type].enableKey, state);
this.applyVariableUpdates();
},
changeValue(type, delta) {
if (type === 'HOVER_DELAY') {
if (!this.HOVER_MENU.enabled) return;
const config = this.HOVER_DELAY;
let newValue = config.current + delta;
if (newValue >= config.MIN && newValue <= config.MAX) {
config.current = newValue;
this._write(config.storageKey, config.current);
this.updateUIDisplay();
}
return;
}
const config = this[type];
if (!config.enabled) return;
let newValue = config.current + delta;
if (type === 'WIDTH') {
const safeVisibleWidth = this.getSafeWidthMax();
if (delta > 0) {
if (config.current >= safeVisibleWidth) return;
if (newValue > safeVisibleWidth) newValue = safeVisibleWidth;
}
if (delta < 0 && config.current > safeVisibleWidth) {
newValue = safeVisibleWidth - config.STEP;
}
}
if (newValue >= config.MIN && newValue <= config.MAX) {
const action = () => {
config.current = newValue;
this._write(config.storageKey, config.current);
this.applyVariableUpdates();
};
if (type === 'FONT') {
this.preserveScrollPosition(action);
} else {
action();
}
}
},
resetValue(type) {
if (type === 'HOVER_DELAY') {
if (!this.HOVER_MENU.enabled) return;
this.HOVER_DELAY.current = this.HOVER_DELAY.DEFAULT;
this._write(this.HOVER_DELAY.storageKey, this.HOVER_DELAY.DEFAULT);
this.updateUIDisplay();
return;
}
const config = this[type];
if (!config.enabled) return;
const action = () => {
config.current = config.DEFAULT;
this._write(config.storageKey, config.current);
this.applyVariableUpdates();
};
if (type === 'FONT') {
this.preserveScrollPosition(action);
} else {
action();
}
},
preserveScrollPosition(actionCallback) {
const scrollContainer = document.querySelector('ms-autoscroll-container');
if (!scrollContainer) {
actionCallback();
return;
}
const originalOverflowAnchor = scrollContainer.style.overflowAnchor;
scrollContainer.style.overflowAnchor = 'none';
const containerRect = scrollContainer.getBoundingClientRect();
const readingLine = containerRect.top + (containerRect.height * 0.5);
const turns = document.querySelectorAll('ms-chat-turn');
let anchor = null;
let closestDist = Infinity;
for (const turn of turns) {
const rect = turn.getBoundingClientRect();
if (rect.top > containerRect.bottom + 500) break;
if (rect.bottom < containerRect.top - 500) continue;
if (rect.top <= readingLine && rect.bottom > readingLine) {
anchor = turn;
break;
}
const distTop = Math.abs(rect.top - readingLine);
const distBottom = Math.abs(rect.bottom - readingLine);
const distance = Math.min(distTop, distBottom);
if (distance < closestDist) {
closestDist = distance;
anchor = turn;
}
}
if (!anchor) {
actionCallback();
scrollContainer.style.overflowAnchor = originalOverflowAnchor;
return;
}
const oldRect = anchor.getBoundingClientRect();
const offsetInElement = readingLine - oldRect.top;
const ratio = oldRect.height > 0 ? offsetInElement / oldRect.height : 0;
actionCallback();
const _ = scrollContainer.scrollHeight;
const newRect = anchor.getBoundingClientRect();
const newOffsetInElement = newRect.height * ratio;
const desiredTop = readingLine - newOffsetInElement;
const diff = newRect.top - desiredTop;
if (Math.abs(diff) > 1) {
scrollContainer.scrollTop += diff;
}
scrollContainer.style.overflowAnchor = originalOverflowAnchor;
},
setupAutoRepeat(button, action) {
let timeout, interval;
const REPEAT_DELAY = 400;
const REPEAT_RATE = 50;
const start = (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
if (button.disabled) return;
action();
timeout = setTimeout(() => {
interval = setInterval(() => {
if (button.disabled) {
stop();
} else {
action();
}
}, REPEAT_RATE);
}, REPEAT_DELAY);
};
const stop = () => {
clearTimeout(timeout);
clearInterval(interval);
};
button.addEventListener('mousedown', start);
button.addEventListener('mouseup', stop);
button.addEventListener('mouseleave', stop);
button.addEventListener('click', (e) => e.stopPropagation());
},
createSvgIcon(pathData) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("width", "18");
svg.setAttribute("height", "18");
svg.setAttribute("fill", "currentColor");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", pathData);
svg.appendChild(path);
return svg;
},
createRow(type, iconPath, minusPath, plusPath, titleGroup, titleMinus, titlePlus, titleReset) {
const rowWrapper = document.createElement('div');
rowWrapper.className = 'quicknav-control-row';
const leftGroup = document.createElement('div');
leftGroup.className = 'quicknav-row-label';
leftGroup.title = titleGroup;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'quicknav-checkbox';
checkbox.checked = this[type].enabled;
checkbox.addEventListener('click', (e) => e.stopPropagation());
checkbox.addEventListener('change', (e) => this.toggleSetting(type, e.target.checked));
if (type === 'FONT') this.ui.fontCheckbox = checkbox;
if (type === 'WIDTH') this.ui.widthCheckbox = checkbox;
const labelIcon = this.createSvgIcon(iconPath);
labelIcon.classList.add('quicknav-icon-label');
leftGroup.append(checkbox, labelIcon);
const container = document.createElement('div');
container.className = 'quicknav-toolbar-group';
if (type === 'FONT') this.ui.fontControls = container;
if (type === 'WIDTH') this.ui.widthControls = container;
const btnMinus = document.createElement('button');
btnMinus.className = 'quicknav-tool-btn';
btnMinus.title = titleMinus;
btnMinus.appendChild(this.createSvgIcon(minusPath));
this.setupAutoRepeat(btnMinus, () => this.changeValue(type, -this[type].STEP));
if (type === 'FONT') this.ui.fontBtnMinus = btnMinus;
if (type === 'WIDTH') this.ui.widthBtnMinus = btnMinus;
const btnReset = document.createElement('button');
btnReset.className = 'quicknav-tool-btn quicknav-tool-indicator';
btnReset.title = titleReset;
btnReset.textContent = `${this[type].current}`;
btnReset.addEventListener('click', (e) => { e.stopPropagation(); this.resetValue(type); });
if (type === 'FONT') this.ui.fontIndicator = btnReset;
if (type === 'WIDTH') this.ui.widthIndicator = btnReset;
const btnPlus = document.createElement('button');
btnPlus.className = 'quicknav-tool-btn';
btnPlus.title = titlePlus;
btnPlus.appendChild(this.createSvgIcon(plusPath));
this.setupAutoRepeat(btnPlus, () => this.changeValue(type, this[type].STEP));
if (type === 'FONT') this.ui.fontBtnPlus = btnPlus;
if (type === 'WIDTH') this.ui.widthBtnPlus = btnPlus;
container.append(btnMinus, btnReset, btnPlus);
rowWrapper.append(leftGroup, container);
return rowWrapper;
},
// Generates the settings dropdown UI with Font, Width, and Hover options.
createControls() {
const container = document.createElement('div');
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.gap = '8px';
const fontIcon = 'M5 4v3h5.5v12h3V7H19V4z';
const fontMinus = 'M19 13H5v-2h14v2z';
const fontPlus = 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z';
const fontRow = this.createRow('FONT', fontIcon, fontMinus, fontPlus,
'Enable custom font size', 'Decrease font size (Alt + -)', 'Increase font size (Alt + +)', 'Reset font size (Alt + 0)');
const widthIconSimple = 'M21 5H3c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 12H3V7h18v10z';
const widthMinus = 'M19 13H5v-2h14v2z';
const widthPlus = 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z';
const widthRow = this.createRow('WIDTH', widthIconSimple, widthMinus, widthPlus,
'Enable custom chat width', 'Decrease chat width (Shift + Alt + -)', 'Increase chat width (Shift + Alt + +)', 'Reset chat width (Shift + Alt + 0)');
const hoverRow = document.createElement('div');
hoverRow.className = 'quicknav-control-row';
const hoverLeft = document.createElement('div');
hoverLeft.className = 'quicknav-row-label';
hoverLeft.title = 'Open navigation menu on hover';
const hoverCheckbox = document.createElement('input');
hoverCheckbox.type = 'checkbox';
hoverCheckbox.className = 'quicknav-checkbox';
hoverCheckbox.checked = this.HOVER_MENU.enabled;
hoverCheckbox.addEventListener('click', (e) => e.stopPropagation());
hoverCheckbox.addEventListener('change', (e) => this.toggleSetting('HOVER_MENU', e.target.checked));
this.ui.hoverCheckbox = hoverCheckbox;
const hoverIconPath = 'M9 11.24V7.5C9 6.12 10.12 5 11.5 5S14 6.12 14 7.5v3.74c1.21-.81 2-2.18 2-3.74C16 5.01 13.99 3 11.5 3S7 5.01 7 7.5c0 1.56.79 2.93 2 3.74z m9.84 4.63l-4.54-2.26c-.17-.07-.35-.11-.54-.11H13v-6c0-.83-.67-1.5-1.5-1.5S10 6.67 10 7.5v10.74l-3.43-.72c-.08-.01-.15-.03-.24-.03-.31 0-.59.13-.79.33l-.79.8 4.94 4.94c.27.27.65.44 1.06.44h6.79c.75 0 1.33-.55 1.44-1.28l.75-5.27c.01-.07.02-.14.02-.2 0-.62-.38-1.16-.91-1.38z';
const hoverIcon = this.createSvgIcon(hoverIconPath);
hoverIcon.classList.add('quicknav-icon-label');
hoverLeft.append(hoverCheckbox, hoverIcon);
const hoverControls = document.createElement('div');
hoverControls.className = 'quicknav-toolbar-group';
this.ui.hoverControls = hoverControls;
const delayMinus = 'M19 13H5v-2h14v2z';
const delayPlus = 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z';
const btnMinus = document.createElement('button');
btnMinus.className = 'quicknav-tool-btn';
btnMinus.title = 'Decrease delay';
btnMinus.appendChild(this.createSvgIcon(delayMinus));
this.setupAutoRepeat(btnMinus, () => this.changeValue('HOVER_DELAY', -this.HOVER_DELAY.STEP));
this.ui.hoverBtnMinus = btnMinus;
const btnReset = document.createElement('button');
btnReset.className = 'quicknav-tool-btn quicknav-tool-indicator';
btnReset.title = `Reset delay to ${this.HOVER_DELAY.DEFAULT}ms`;
btnReset.textContent = `${this.HOVER_DELAY.current}`;
btnReset.addEventListener('click', (e) => { e.stopPropagation(); this.resetValue('HOVER_DELAY'); });
this.ui.hoverIndicator = btnReset;
const btnPlus = document.createElement('button');
btnPlus.className = 'quicknav-tool-btn';
btnPlus.title = 'Increase delay';
btnPlus.appendChild(this.createSvgIcon(delayPlus));
this.setupAutoRepeat(btnPlus, () => this.changeValue('HOVER_DELAY', this.HOVER_DELAY.STEP));
this.ui.hoverBtnPlus = btnPlus;
hoverControls.append(btnMinus, btnReset, btnPlus);
hoverRow.append(hoverLeft, hoverControls);
container.append(fontRow, widthRow, hoverRow);
this.updateUIDisplay();
return container;
}
};
// --- MODULE: UI Manager ---
const UIManager = {
customTooltip: null,
tooltipTimeout: null,
badgeFadeTimeout: null,
lastScrollTime: 0,
lastScrollTop: 0,
isHoveringOnNav: false,
isThrottled: false,
footerElement: null,
toolbarElement: null,
navContainerElement: null,
listenedTurnElement: null,
_handleTurnMouseMove: null,
_handleTurnMouseLeave: null,
injectionIntervals: [],
// Initializes the UI Manager, loads styles, and prepares global elements.
create(targetNode) {
StyleManager.init();
this.injectStyles();
this.createGlobalElements();
this.createAndInjectUI();
this.cacheStaticElements();
this._handleTurnMouseMove = this._handleTurnMouseMove.bind(this);
this._handleTurnMouseLeave = this._handleTurnMouseLeave.bind(this);
document.addEventListener('click', this.closeSettingsMenu.bind(this));
window.addEventListener('resize', this.closeSettingsMenu.bind(this));
},
// Cleans up all injected elements and listeners.
destroy() {
this.injectionIntervals.forEach(clearInterval);
this.injectionIntervals = [];
const ui = document.getElementById('chat-nav-container');
if (ui) ui.remove();
const settingsWrapper = document.querySelector('.quicknav-settings-wrapper');
if (settingsWrapper) settingsWrapper.remove();
const dropdown = document.getElementById('quicknav-settings-dropdown');
if (dropdown) dropdown.remove();
const menu = document.getElementById('chat-nav-menu-container');
if (menu) menu.remove();
const badge = document.getElementById('quicknav-badge-floater');
if (badge) badge.remove();
const tooltip = document.getElementById('quicknav-custom-tooltip');
if (tooltip) tooltip.remove();
this.customTooltip = null;
this.footerElement = null;
this.toolbarElement = null;
this.navContainerElement = null;
if (this.listenedTurnElement) {
this.listenedTurnElement.removeEventListener('mousemove', this._handleTurnMouseMove);
this.listenedTurnElement.removeEventListener('mouseleave', this._handleTurnMouseLeave);
this.listenedTurnElement = null;
}
document.removeEventListener('click', this.closeSettingsMenu.bind(this));
window.removeEventListener('resize', this.closeSettingsMenu.bind(this));
},
// Caches static references to UI boundaries for the badge positioning.
cacheStaticElements() {
this.footerElement = document.querySelector('ms-chunk-editor footer, section.chunk-editor-main > footer');
this.toolbarElement = document.querySelector('ms-toolbar');
},
showBadge() {
const floater = document.getElementById('quicknav-badge-floater');
if (!floater) return;
this.cancelHideBadge();
floater.style.opacity = '1';
},
hideBadge(delay = 1000, force = false) {
const floater = document.getElementById('quicknav-badge-floater');
if (!floater || (this.isHoveringOnNav && !force)) return;
this.cancelHideBadge();
if (force) {
floater.classList.add('quicknav-badge-notransition');
floater.style.opacity = '0';
requestAnimationFrame(() => {
floater.classList.remove('quicknav-badge-notransition');
});
} else {
this.badgeFadeTimeout = setTimeout(() => {
floater.style.opacity = '0';
}, delay);
}
},
cancelHideBadge() {
clearTimeout(this.badgeFadeTimeout);
},
setupMessageObserver() {
const scrollContainer = document.querySelector('ms-autoscroll-container');
const messages = document.querySelectorAll('.message-turn');
if (!scrollContainer || !messages.length) return;
const observerOptions = { root: scrollContainer, rootMargin: '0px', threshold: 0.1 };
const observer = new IntersectionObserver(this.handleMessageVisibilityChange.bind(this), observerOptions);
messages.forEach(msg => observer.observe(msg));
},
handleMessageVisibilityChange(entries) {
const visibleEntries = entries.filter(entry => entry.isIntersecting);
if (visibleEntries.length === 0) return;
const bottomMostEntry = visibleEntries.reduce((prev, current) => {
return prev.boundingClientRect.top > current.boundingClientRect.top ? prev : current;
});
if (bottomMostEntry && bottomMostEntry.target !== this.currentVisibleMessage) {
this.currentVisibleMessage = bottomMostEntry.target;
this.showBadge();
this.updateScrollPercentage();
this.hideBadge();
}
},
handlePageScroll() {
if (this.isThrottled) return;
this.isThrottled = true;
setTimeout(() => { this.isThrottled = false; }, 200);
const SCROLL_SPEED_THRESHOLD = 500;
const scrollContainer = document.querySelector('ms-autoscroll-container');
if (!scrollContainer) return;
const currentTime = performance.now();
const currentScrollTop = scrollContainer.scrollTop;
const deltaTime = currentTime - this.lastScrollTime;
if (deltaTime > 0) {
const deltaScroll = currentScrollTop - this.lastScrollTop;
const scrollSpeed = Math.abs(deltaScroll / deltaTime) * 1000;
if (scrollSpeed > SCROLL_SPEED_THRESHOLD) {
this.showBadge();
this.updateScrollPercentage();
this.hideBadge(1000);
}
}
this.lastScrollTop = currentScrollTop;
this.lastScrollTime = currentTime;
},
createGlobalElements() {
if (!document.getElementById('quicknav-badge-floater')) {
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 (!document.getElementById('quicknav-custom-tooltip')) {
this.customTooltip = document.createElement('div');
this.customTooltip.id = 'quicknav-custom-tooltip';
document.body.appendChild(this.customTooltip);
}
},
debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
},
// Injects CSS styles for QuickNav UI, including Font, Layout, and slower Visual Effects.
injectStyles() {
if (document.getElementById('chat-nav-styles')) return;
const styleSheet = document.createElement("style");
styleSheet.id = 'chat-nav-styles';
styleSheet.textContent = `
.chat-turn-container {}
@keyframes google-text-flow { to { background-position: 200% center; } }
@keyframes donate-blue-flow { to { background-position: 200% center; } }
@keyframes load-all-flow { to { background-position: 200% center; } }
/* NEW: Compact High-Intensity Blue Plasma Animation */
@keyframes quicknav-plasma-blue {
0% {
box-shadow: 0 0 0 0 rgba(66, 133, 244, 0.0);
border-color: #5f6368;
color: #8ab4f8;
background-color: transparent;
}
50% {
/* Tighter, more concentrated glow */
box-shadow: 0 0 10px 2px rgba(66, 133, 244, 0.85), inset 0 0 6px rgba(138, 180, 248, 0.6);
border-color: #aecbfa;
color: #ffffff;
background-color: rgba(66, 133, 244, 0.3);
}
100% {
box-shadow: 0 0 0 0 rgba(66, 133, 244, 0.0);
border-color: #5f6368;
color: #8ab4f8;
background-color: transparent;
}
}
@keyframes quicknav-loading-flow { to { background-position: -200% center; } }
.google-text-animated, .google-text-flash { background: linear-gradient(90deg, #8ab4f8, #e67c73, #f7cb73, #57bb8a, #8ab4f8); background-size: 200% auto; -webkit-background-clip: text; background-clip: text; color: transparent !important; }
.google-text-animated { animation: google-text-flow 10s linear infinite; }
.google-text-flash { animation: google-text-flow 1.5s ease-in-out 1; }
.donate-button-animated { background: linear-gradient(90deg, #a8c7fa, #8ab4f8, #669df6, #8ab4f8, #a8c7fa); background-size: 200% auto; -webkit-background-clip: text; background-clip: text; color: transparent !important; animation: donate-blue-flow 8s ease-in-out infinite; }
ms-autoscroll-container:focus { outline: none; }
#chat-nav-container { display: flex; justify-content: center; align-items: center; gap: 12px; margin: 2px auto; width: 100%; box-sizing: border-box; position: relative; z-index: 2147483647; }
.counter-wrapper { position: relative; pointer-events: none; z-index: 9999; }
/* UPDATED: Added hardware acceleration props with vendor prefixes for max compatibility */
.chat-nav-button, #chat-nav-counter {
background-color: transparent;
border: 1px solid var(--ms-on-surface-variant, #888888);
transition: transform 0.1s ease-out, background-color 0.15s ease-in-out, border-color 0.15s ease, box-shadow 0.2s ease;
pointer-events: auto;
user-select: none;
cursor: pointer;
/* Smooth rendering tricks with max compatibility */
-webkit-transform: translateZ(0);
transform: translateZ(0);
-webkit-font-smoothing: antialiased;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
box-shadow: 0 0 1px rgba(0,0,0,0);
}
.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: #669df6; }
#nav-top, #nav-bottom, #chat-nav-counter { border-color: #5f6368; }
#chat-nav-counter { font-family: 'Google Sans', sans-serif; font-size: 14px; 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 { -webkit-transform: scale(0.95) translateZ(0); transform: scale(0.95) translateZ(0); }
/* UPDATED: Slower animation (1.8s) */
#nav-bottom.auto-click-active { animation: quicknav-plasma-blue 2.4s ease-in-out infinite; }
#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 { box-shadow: inset 0 0 0 1px var(--ms-on-surface-variant, #9aa0a6) !important; }
.response-turn-highlight { box-shadow: inset 0 0 0 1px var(--ms-primary, #8ab4f8) !important; }
.quicknav-title { flex: 2; text-align: center; font-family: 'Google Sans', 'Inter Tight', sans-serif; font-size: 14px; user-select: text; font-weight: 600; }
/* --- SETTINGS DROPDOWN --- */
.quicknav-settings-wrapper {
margin-left: auto;
margin-right: 12px;
display: flex;
align-items: center;
}
.quicknav-dropdown-trigger {
border: 1px solid transparent;
background: transparent;
color: #8ab4f8;
height: 28px;
min-width: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease-in-out;
padding: 0;
}
.quicknav-dropdown-trigger:hover, .quicknav-dropdown-trigger.active {
background-color: rgba(138, 180, 248, 0.1);
border-color: rgba(138, 180, 248, 0.5);
}
.quicknav-dropdown-menu {
position: fixed;
background-color: #ffffff;
border: 1px solid #dadce0;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
padding: 8px;
z-index: 2147483648;
min-width: 160px;
display: none;
flex-direction: column;
gap: 8px;
}
.quicknav-dropdown-menu.visible { display: flex; }
/* Control Row Styling */
.quicknav-control-row { display: flex; align-items: center; gap: 8px; padding: 0 4px; justify-content: space-between; }
.quicknav-checkbox { cursor: pointer; width: 16px; height: 16px; accent-color: #8ab4f8; margin: 0; }
.quicknav-toolbar-group { display: flex; align-items: center; gap: 4px; justify-content: center; transition: opacity 0.2s ease, filter 0.2s ease; }
.quicknav-toolbar-group.disabled { opacity: 0.4; pointer-events: none; filter: grayscale(100%); }
.quicknav-tool-btn {
border: 1px solid transparent;
background: transparent;
color: #8ab4f8;
height: 28px;
min-width: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease-in-out;
padding: 0 4px;
}
.quicknav-tool-indicator {
font-family: 'Google Sans', sans-serif;
font-size: 13px;
font-weight: 500;
min-width: 40px;
text-align: center;
}
.quicknav-tool-btn:hover {
background-color: rgba(138, 180, 248, 0.1);
border-color: rgba(138, 180, 248, 0.5);
}
.quicknav-tool-btn:active { background-color: rgba(138, 180, 248, 0.25); }
/* Disabled state styling for buttons */
.quicknav-tool-btn:disabled {
color: var(--ms-on-surface-variant, #9aa0a6);
opacity: 0.5;
cursor: default;
background-color: transparent;
border-color: transparent;
}
.quicknav-row-label {
display: flex;
align-items: center;
gap: 6px;
}
.quicknav-icon-label {
width: 18px;
height: 18px;
color: #5f6368;
}
body.dark-theme .quicknav-dropdown-menu {
background-color: #202124;
border-color: #5f6368;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
}
body.dark-theme .quicknav-icon-label { color: #9aa0a6; }
/* --- MENU & TOOLTIP --- */
#chat-nav-menu-container { background-color: #f1f3f4; border: 2px solid #1a73e8; }
#chat-nav-menu { background-color: #f1f3f4; }
.chat-nav-menu-item { background-color: #ffffff; color: #202124; }
.chat-nav-menu-item:hover { background-color: #e8eaed; }
.chat-nav-menu-item.menu-item-focused { background-color: #dfe1e5; }
.chat-nav-menu-item.loading-in-progress, .chat-nav-menu-item.loading-in-progress:hover, .chat-nav-menu-item.loading-in-progress.menu-item-focused { background: linear-gradient(100deg, #f1f3f4 20%, #d2e3fc 40%, #d2e3fc 60%, #f1f3f4 80%); background-size: 200% 100%; animation: quicknav-loading-flow 1.8s linear infinite; }
.chat-nav-menu-header { background-color: #f1f3f4; border-bottom: 1px solid #1a73e8; }
.header-button { font-family: 'Google Sans', sans-serif; text-decoration: none; font-size: 12px; padding: 4px 10px; border-radius: 16px; transition: background-color 0.15s ease-in-out, opacity 0.15s ease-in-out, box-shadow 0.15s ease-in-out; border: 1px solid #669df6; cursor: pointer; font-weight: 500; }
.header-button:disabled { opacity: 0.5; cursor: not-allowed; }
.header-button:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--ms-surface-1, #ffffff), 0 0 0 4px var(--ms-primary, #8ab4f8); }
#chat-nav-load-button { background-color: #e8f0fe; color: #1967d2; }
#chat-nav-load-button:hover:not(:disabled) { background-color: #d2e3fc; }
#chat-nav-load-button.loading-active { color: #ffffff; background: linear-gradient(90deg, #1967d2, #4285f4, #1967d2); background-size: 200% auto; animation: load-all-flow 2s linear infinite; }
#chat-nav-menu::-webkit-scrollbar-track { background: #e8eaed; }
#chat-nav-menu::-webkit-scrollbar-thumb { background-color: #dfe1e5; }
#chat-nav-menu::-webkit-scrollbar-thumb:hover { background-color: #9aa0a6; }
#quicknav-custom-tooltip { background-color: #f1f3f4; color: #202124; border: 1px solid #dadce0; }
.response-item-bg .menu-item-text { color: #174ea6; }
body.dark-theme #chat-nav-menu-container { background-color: #191919; border-color: #8ab4f8; }
body.dark-theme #chat-nav-menu { background-color: #191919; }
body.dark-theme .chat-nav-menu-item { background-color: #202124; color: #e8eaed; }
body.dark-theme .chat-nav-menu-item:hover { background-color: #3c4043; }
body.dark-theme .chat-nav-menu-item.menu-item-focused { background-color: #5f6368; }
body.dark-theme .chat-nav-menu-item.loading-in-progress, body.dark-theme .chat-nav-menu-item.loading-in-progress:hover, body.dark-theme .chat-nav-menu-item.loading-in-progress.menu-item-focused { background: linear-gradient(100deg, #202124 20%, #3c4043 40%, #3c4043 60%, #202124 80%); background-size: 200% 100%; animation: quicknav-loading-flow 1.8s linear infinite; }
body.dark-theme .chat-nav-menu-header { background-color: #191919; border-color: #8ab4f8; }
body.dark-theme .header-button { background-color: #3c4043; color: #e8eaed; border-color: #8ab4f8; }
body.dark-theme #chat-nav-load-button { background-color: #28354a; color: #a8c7fa; }
body.dark-theme .header-button:hover:not(:disabled) { background-color: #5f6368; }
body.dark-theme #chat-nav-load-button:hover:not(:disabled) { background-color: #3c4043; }
body.dark-theme #chat-nav-load-button.loading-active { color: #202124; background: linear-gradient(90deg, #8ab4f8, #a8c7fa, #8ab4f8); background-size: 200% auto; animation: load-all-flow 2s linear infinite; }
body.dark-theme #chat-nav-menu::-webkit-scrollbar-track { background: #202124; }
body.dark-theme #chat-nav-menu::-webkit-scrollbar-thumb { background-color: #5f6368; }
body.dark-theme #chat-nav-menu::-webkit-scrollbar-thumb:hover { background-color: #9aa0a6; }
body.dark-theme #quicknav-custom-tooltip { background-color: #2d2d2d; color: #e0e0e0; border: 1px solid #555; }
body.dark-theme .response-item-bg .menu-item-text { color: var(--ms-primary, #8ab4f8); }
#quicknav-badge-floater { position: fixed; z-index: 99998; opacity: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: none; padding: 4px 3px; border-radius: 8px; font-family: 'Google Sans', sans-serif; transition: opacity 0.15s ease-in-out; min-width: 28px; box-sizing: border-box; }
.quicknav-badge-notransition { transition: none !important; }
#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; }
/* FIXED: Removed max-width: 800px constraint that was blocking resize. Changed to 95vw. */
#chat-nav-menu-container { display: flex; flex-direction: column; position: fixed; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.25); max-height: 90vh; z-index: 99999; max-width: 95vw; min-width: 300px; box-sizing: border-box; visibility: hidden; opacity: 0; pointer-events: none; transition: opacity 0.15s ease-in-out, visibility 0s linear 0.15s; }
#chat-nav-menu-container.visible { visibility: visible; opacity: 1; pointer-events: auto; transition: opacity 0.15s ease-in-out, visibility 0s linear 0s; }
#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; }
.chat-nav-menu-item { display: flex; align-items: center; 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; }
.menu-item-number { font-weight: 500; margin-right: 8px; flex-shrink: 0; }
.prompt-number-color { color: var(--ms-on-surface-variant, #9aa0a6); }
.response-number-color { color: var(--ms-primary, #8ab4f8); }
.menu-item-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.prompt-item-bg { border-left: 3px solid var(--ms-on-surface-variant, #9aa0a6); margin-left: 32px; }
.response-item-bg { border-left: 3px solid var(--ms-primary, #8ab4f8); border-bottom: 1px solid var(--ms-primary, #8ab4f8); }
.chat-nav-menu-header { flex-shrink: 0; z-index: 1; display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; border-radius: 10px 10px 0 0; }
.header-controls { flex: 1; display: flex; align-items: center; gap: 8px; }
.header-controls.left { justify-content: flex-start; }
.header-controls.right { justify-content: flex-end; }
#chat-nav-menu::-webkit-scrollbar { width: 8px; }
#chat-nav-loader-status { font-family: 'Google Sans', sans-serif; font-size: 12px; color: var(--ms-on-surface-variant, #9aa0a6); padding: 4px 10px; font-weight: 600; }
#quicknav-custom-tooltip { position: fixed; z-index: 100000; border-radius: 6px; padding: 6px 10px; font-size: 13px; font-family: 'Google Sans', sans-serif; max-width: 40vw; pointer-events: none; opacity: 0; transition: opacity 0.15s ease-in-out; white-space: normal; }
/* RESIZERS */
.quicknav-menu-resizer { position: absolute; top: 0; bottom: 0; width: 20px; cursor: ew-resize; z-index: 2147483647; touch-action: none; background: transparent; transition: background-color 0.15s; }
.quicknav-menu-resizer:hover { background-color: rgba(138, 180, 248, 0.2); }
.quicknav-resizer-left { left: -10px; }
.quicknav-resizer-right { right: -10px; }
/* GLOBAL RESIZING STATE */
body.quicknav-resizing { cursor: ew-resize !important; user-select: none !important; }
/* --- CODE BLOCK NAVIGATION (Google Blue Theme) --- */
.code-block-nav-container { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.code-nav-button { background: transparent; border: 1px solid #669df6; color: #8ab4f8; height: 28px; border-radius: 8px; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background-color 0.15s ease, transform 0.1s ease, border-color 0.15s ease, color 0.15s ease; padding: 0 12px; }
.code-nav-counter { font-family: 'Google Sans', sans-serif; font-size: 12px; font-weight: 500; color: #8ab4f8; user-select: none; }
.code-nav-button:hover:not(:disabled) { background-color: rgba(138, 180, 248, 0.15); }
.code-nav-button:active:not(:disabled) { transform: scale(0.95); }
.code-nav-button:disabled { opacity: 0.5; cursor: not-allowed; color: var(--ms-on-surface-variant, #9aa0a6); border-color: var(--ms-on-surface-variant, #9aa0a6); }
`;
document.head.appendChild(styleSheet);
},
waitForElement(selector, callback, maxAttempts = 50, intervalMs = 500) {
let attempts = 0;
const interval = setInterval(() => {
const element = document.querySelector(selector);
if (element) {
clearInterval(interval);
callback(element);
const idx = this.injectionIntervals.indexOf(interval);
if (idx > -1) this.injectionIntervals.splice(idx, 1);
} else {
attempts++;
if (attempts >= maxAttempts) {
clearInterval(interval);
console.warn(`[QuickNav] Gave up waiting for ${selector} after ${maxAttempts} attempts.`);
const idx = this.injectionIntervals.indexOf(interval);
if (idx > -1) this.injectionIntervals.splice(idx, 1);
}
}
}, intervalMs);
this.injectionIntervals.push(interval);
},
// Toggles the dropdown menu, allowing forced state for hover interactions.
toggleSettingsMenu(e, forceState = null) {
e.stopPropagation();
const dropdown = document.getElementById('quicknav-settings-dropdown');
const trigger = e.currentTarget;
if (dropdown && trigger) {
const isVisible = dropdown.classList.contains('visible');
const shouldOpen = forceState !== null ? forceState : !isVisible;
if (!shouldOpen) {
dropdown.classList.remove('visible');
trigger.classList.remove('active');
} else {
StyleManager.updateUIDisplay();
dropdown.classList.add('visible');
trigger.classList.add('active');
const rect = trigger.getBoundingClientRect();
const menuWidth = dropdown.offsetWidth;
const menuHeight = dropdown.offsetHeight;
let leftPos = rect.right - menuWidth;
if (leftPos < 4) leftPos = 4;
dropdown.style.top = `${rect.top - menuHeight - 8}px`;
dropdown.style.left = `${leftPos}px`;
}
}
},
// Closes the settings menu if clicked outside.
closeSettingsMenu(e) {
const dropdown = document.getElementById('quicknav-settings-dropdown');
const trigger = document.querySelector('.quicknav-dropdown-trigger');
if (dropdown && dropdown.classList.contains('visible')) {
if (!dropdown.contains(e.target) && (!trigger || !trigger.contains(e.target))) {
dropdown.classList.remove('visible');
if (trigger) trigger.classList.remove('active');
}
}
},
// Creates and injects the UI elements into the DOM, adding hover logic for settings.
createAndInjectUI() {
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 the first message (Shift + Alt + PgUp)', pathTop);
const btnUp = this.createButton('nav-up', 'Go to the previous message (Alt + PgUp)', pathUp);
const counterWrapper = document.createElement('div');
counterWrapper.className = 'counter-wrapper';
const counter = document.createElement('span');
counter.id = 'chat-nav-counter';
counter.title = 'Open navigation menu (Alt + M)';
counter.tabIndex = 0;
counter.setAttribute('role', 'button');
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', 'Go to the next message (Alt + PgDown)', pathDown);
const btnBottom = this.createButton('nav-bottom', 'Go to the last message (Shift + Alt + PgDown)', 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');
const resizerLeft = document.createElement('div');
resizerLeft.className = 'quicknav-menu-resizer quicknav-resizer-left';
resizerLeft.title = 'Drag to resize';
const resizerRight = document.createElement('div');
resizerRight.className = 'quicknav-menu-resizer quicknav-resizer-right';
resizerRight.title = 'Drag to resize';
menuContainer.append(resizerLeft, resizerRight);
this.setupMenuResizer(resizerLeft, -1, menuContainer);
this.setupMenuResizer(resizerRight, 1, menuContainer);
document.body.appendChild(menuContainer);
}
counterWrapper.append(counter);
const settingsWrapper = document.createElement('div');
settingsWrapper.className = 'quicknav-settings-wrapper';
settingsWrapper.style.cssText = `
position: absolute;
right: max(10px, calc(((100% - var(--quicknav-chat-width, 1000px)) / 2) + 10px));
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
z-index: 10;
margin: 0;
`;
const triggerBtn = document.createElement('button');
triggerBtn.className = 'quicknav-dropdown-trigger';
triggerBtn.title = 'QuickNav Settings';
const svgDots = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgDots.setAttribute('height', '24px');
svgDots.setAttribute('viewBox', '0 0 24 24');
svgDots.setAttribute('width', '24px');
svgDots.setAttribute('fill', 'currentColor');
const pathDots = document.createElementNS("http://www.w3.org/2000/svg", 'path');
pathDots.setAttribute('d', 'M6 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm12 0c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm-6 0c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5z');
svgDots.appendChild(pathDots);
triggerBtn.appendChild(svgDots);
triggerBtn.addEventListener('click', this.toggleSettingsMenu.bind(this));
triggerBtn.addEventListener('mouseenter', (e) => {
clearTimeout(this.settingsCloseTimeout);
this.toggleSettingsMenu(e, true);
});
const closeMenuDelayed = () => {
this.settingsCloseTimeout = setTimeout(() => {
const dropdown = document.getElementById('quicknav-settings-dropdown');
const trigger = document.querySelector('.quicknav-dropdown-trigger');
if (dropdown) dropdown.classList.remove('visible');
if (trigger) trigger.classList.remove('active');
}, 300);
};
triggerBtn.addEventListener('mouseleave', closeMenuDelayed);
let dropdownMenu = document.getElementById('quicknav-settings-dropdown');
if (!dropdownMenu) {
dropdownMenu = document.createElement('div');
dropdownMenu.id = 'quicknav-settings-dropdown';
dropdownMenu.className = 'quicknav-dropdown-menu';
const styleControls = StyleManager.createControls();
dropdownMenu.appendChild(styleControls);
document.body.appendChild(dropdownMenu);
}
dropdownMenu.addEventListener('mouseenter', () => clearTimeout(this.settingsCloseTimeout));
dropdownMenu.addEventListener('mouseleave', closeMenuDelayed);
settingsWrapper.appendChild(triggerBtn);
navContainer.append(btnTop, btnUp, counterWrapper, btnDown, btnBottom, settingsWrapper);
this.navContainerElement = navContainer;
const injectNavBar = (anchor) => {
if (document.getElementById('chat-nav-container')) return;
if (anchor && anchor.parentNode) {
anchor.parentNode.insertBefore(navContainer, anchor);
this.attachNavigationLogic(btnTop, btnUp, btnDown, btnBottom, counter, menuContainer);
this.updateCounterDisplay();
navContainer.addEventListener('mouseenter', () => {
this.isHoveringOnNav = true;
this.updateScrollPercentage();
this.showBadge();
});
navContainer.addEventListener('mouseleave', () => {
this.isHoveringOnNav = false;
this.hideBadge(1000);
});
}
};
this.waitForElement('ms-chunk-editor footer, section.chunk-editor-main > footer', injectNavBar);
this.waitForElement('ms-prompt-input-wrapper', (inputWrapper) => {
if (!document.getElementById('chat-nav-container')) {
injectNavBar(inputWrapper);
}
});
},
createButton(id, title, pathData) {
const button = document.createElement('button');
button.id = id;
button.className = 'chat-nav-button';
button.title = title;
button.setAttribute('aria-label', title);
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;
},
// Attaches event listeners and logic to the navigation UI buttons and menu.
attachNavigationLogic(btnTop, btnUp, btnDown, btnBottom, counter, menuContainer) {
const lastIndex = () => ChatController.allTurns.length - 1;
ChatController.setupHoldableButton(btnTop, () => {
ChatController.scrollToAbsoluteTop();
});
ChatController.setupHoldableButton(btnUp, async () => {
const last = lastIndex();
const scrollContainer = document.querySelector('ms-autoscroll-container');
if (ChatController.currentIndex === last && last > -1 && scrollContainer) {
const currentTurn = ChatController.allTurns[last];
const isScrolledPastTop = scrollContainer.scrollTop > currentTurn.offsetTop + 10;
if (isScrolledPastTop) {
await ChatController.navigateToIndex(last, 'center');
return;
}
}
if (ChatController.currentIndex === 0) {
ChatController.scrollToAbsoluteTop();
return;
}
await ChatController.navigateToIndex(ChatController.currentIndex - 1, 'center');
});
ChatController.setupHoldableButton(btnDown, async () => {
const last = lastIndex();
if (ChatController.currentIndex < last) {
await ChatController.navigateToIndex(ChatController.currentIndex + 1, 'center');
} else {
const scrollContainer = document.querySelector('ms-autoscroll-container');
if (!scrollContainer) return;
if (ChatController.isDownButtonAtEndToggle) {
await ChatController.navigateToIndex(last, 'center');
ChatController.isDownButtonAtEndToggle = false;
} else {
UIManager.hideBadge(0, true);
ChatController.isScrollingProgrammatically = true;
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth' });
setTimeout(() => {
ChatController.isScrollingProgrammatically = false;
UIManager.showBadge();
UIManager.updateScrollPercentage();
UIManager.hideBadge(3000);
ChatController.focusChatContainer();
}, 800);
ChatController.isDownButtonAtEndToggle = true;
}
}
});
let ignoreNextBottomClick = false;
const bottomNavAction = async () => {
UIManager.hideBadge(0, true);
const last = lastIndex();
if (ChatController.currentIndex === last) {
const scrollContainer = document.querySelector('ms-autoscroll-container');
if (scrollContainer) {
const isSmooth = !ChatController.isBottomHoldActive;
ChatController.isScrollingProgrammatically = true;
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: isSmooth ? 'smooth' : 'auto' });
setTimeout(() => {
ChatController.isScrollingProgrammatically = false;
UIManager.showBadge();
UIManager.updateScrollPercentage();
UIManager.hideBadge(3000);
ChatController.focusChatContainer();
}, isSmooth ? 800 : 50);
}
} else {
await ChatController.navigateToIndex(last, 'center');
}
};
btnBottom.addEventListener('mousedown', (e) => {
if (ChatController.isBottomHoldActive) return;
ChatController.bottomHoldTimeout = setTimeout(() => {
ChatController.isBottomHoldActive = true;
btnBottom.classList.add('auto-click-active');
ignoreNextBottomClick = true;
bottomNavAction();
ChatController.bottomHoldInterval = setInterval(bottomNavAction, 150);
}, 750);
});
const stopBottomHoldDetector = () => { clearTimeout(ChatController.bottomHoldTimeout); };
btnBottom.addEventListener('mouseup', stopBottomHoldDetector);
btnBottom.addEventListener('mouseleave', stopBottomHoldDetector);
btnBottom.addEventListener('click', (e) => {
if (ignoreNextBottomClick) {
ignoreNextBottomClick = false;
return;
}
if (ChatController.isBottomHoldActive) {
ChatController.stopBottomHold();
} else {
if (e.target.closest('#nav-bottom')) {
bottomNavAction();
}
}
});
// --- Toggle Menu Logic (Click + Hover) ---
counter.addEventListener('click', (e) => { e.stopPropagation(); this.toggleNavMenu(); });
let hoverOpenTimeout = null;
const handleHoverOpen = () => {
if (!StyleManager.HOVER_MENU.enabled) return;
clearTimeout(this.menuCloseTimeout);
if (!menuContainer.classList.contains('visible')) {
clearTimeout(hoverOpenTimeout);
hoverOpenTimeout = setTimeout(() => {
this.toggleNavMenu();
}, StyleManager.HOVER_DELAY.current);
}
};
const handleHoverClose = () => {
if (!StyleManager.HOVER_MENU.enabled) return;
clearTimeout(hoverOpenTimeout);
this.menuCloseTimeout = setTimeout(() => {
if (menuContainer.classList.contains('visible')) {
this.toggleNavMenu();
}
}, 300);
};
counter.addEventListener('mouseenter', handleHoverOpen);
counter.addEventListener('mouseleave', handleHoverClose);
menuContainer.addEventListener('mouseenter', () => {
clearTimeout(this.menuCloseTimeout);
clearTimeout(hoverOpenTimeout);
});
menuContainer.addEventListener('mouseleave', handleHoverClose);
counter.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.toggleNavMenu();
} else if (e.key === 'Tab' && menuContainer.classList.contains('visible')) {
e.preventDefault();
const menuList = document.getElementById('chat-nav-menu');
const donateBtn = document.getElementById('chat-nav-donate-link');
if (e.shiftKey) {
if (donateBtn) donateBtn.focus();
} else {
if (menuList) menuList.focus();
}
}
});
menuContainer.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
const menuList = document.getElementById('chat-nav-menu');
const loadBtn = document.getElementById('chat-nav-load-button');
const donateBtn = document.getElementById('chat-nav-donate-link');
const activeEl = document.activeElement;
if (e.shiftKey) {
if (activeEl === menuList) counter.focus();
else if (activeEl === loadBtn) menuList.focus();
else if (activeEl === donateBtn) loadBtn.focus();
} else {
if (activeEl === menuList) loadBtn.focus();
else if (activeEl === loadBtn) donateBtn.focus();
else if (activeEl === donateBtn) counter.focus();
}
return;
}
const items = menuContainer.querySelectorAll('.chat-nav-menu-item');
let newIndex = ChatController.menuFocusedIndex;
let shouldUpdateFocus = true;
switch (e.key) {
case 'ArrowDown': e.preventDefault(); if (items.length > 0) newIndex = (ChatController.menuFocusedIndex + 1) % items.length; break;
case 'ArrowUp': e.preventDefault(); if (items.length > 0) newIndex = (ChatController.menuFocusedIndex - 1 + items.length) % items.length; break;
case 'PageDown': e.preventDefault(); if (items.length > 0) newIndex = Math.min(items.length - 1, ChatController.menuFocusedIndex + ChatController.JUMP_DISTANCE); break;
case 'PageUp': e.preventDefault(); if (items.length > 0) newIndex = Math.max(0, ChatController.menuFocusedIndex - ChatController.JUMP_DISTANCE); break;
case 'Home': e.preventDefault(); if (items.length > 0) newIndex = 0; break;
case 'End': e.preventDefault(); if (items.length > 0) newIndex = items.length - 1; break;
case 'Enter':
e.preventDefault();
const activeEl = document.activeElement;
if (activeEl && activeEl.matches('button, a[href]') && menuContainer.contains(activeEl)) {
activeEl.click();
} else if (ChatController.menuFocusedIndex !== -1 && items[ChatController.menuFocusedIndex]) {
items[ChatController.menuFocusedIndex].click();
}
shouldUpdateFocus = false;
break;
case 'Escape': e.preventDefault(); this.toggleNavMenu(); shouldUpdateFocus = false; break;
default: shouldUpdateFocus = false; break;
}
if (shouldUpdateFocus && newIndex !== ChatController.menuFocusedIndex) { this.updateMenuFocus(items, newIndex); }
});
},
// Handles mouse movement over the highlighted turn to show/hide the badge.
_handleTurnMouseMove(e) {
const hotzoneWidth = 24;
const rect = this.listenedTurnElement.getBoundingClientRect();
const cursorX = e.clientX;
if (cursorX >= rect.left && cursorX <= rect.left + hotzoneWidth ||
cursorX >= rect.right - hotzoneWidth && cursorX <= rect.right) {
this.showBadge();
this.updateScrollPercentage();
} else {
this.hideBadge(1000);
}
},
_handleTurnMouseLeave() {
this.hideBadge(1000);
},
// Updates the visual highlight, ensuring only one turn is highlighted at a time.
updateHighlight(oldIndex, newIndex) {
if (oldIndex > -1 && oldIndex < ChatController.allTurns.length) {
const oldTurn = ChatController.allTurns[oldIndex];
const oldTurnContainer = oldTurn.querySelector('.chat-turn-container');
if (oldTurnContainer) {
oldTurnContainer.classList.remove('prompt-turn-highlight', 'response-turn-highlight');
}
}
if (this.listenedTurnElement) {
this.listenedTurnElement.removeEventListener('mousemove', this._handleTurnMouseMove);
this.listenedTurnElement.removeEventListener('mouseleave', this._handleTurnMouseLeave);
this.listenedTurnElement = null;
}
const floater = document.getElementById('quicknav-badge-floater');
if (!floater) return;
if (newIndex > -1 && newIndex < ChatController.allTurns.length) {
const newTurn = ChatController.allTurns[newIndex];
const isPrompt = ChatController.isUserPrompt(newTurn);
const newContainer = newTurn.querySelector('.chat-turn-container');
if (newContainer) {
newContainer.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;
this.listenedTurnElement = newTurn;
this.listenedTurnElement.addEventListener('mousemove', this._handleTurnMouseMove);
this.listenedTurnElement.addEventListener('mouseleave', this._handleTurnMouseLeave);
}
},
updateCounterDisplay() {
let currentNumSpan = document.getElementById('chat-nav-current-num');
let totalNumSpan = document.getElementById('chat-nav-total-num');
if (!currentNumSpan || !totalNumSpan) return;
const current = ChatController.currentIndex > -1 ? ChatController.currentIndex + 1 : '-';
const total = ChatController.allTurns.length;
currentNumSpan.textContent = current;
totalNumSpan.textContent = total;
currentNumSpan.classList.remove('chat-nav-current-grey', 'chat-nav-current-blue');
if (ChatController.currentIndex === total - 1 && total > 0) {
currentNumSpan.classList.add('chat-nav-current-blue');
} else {
currentNumSpan.classList.add('chat-nav-current-grey');
}
},
// Toggles the visibility and state of the main navigation menu.
toggleNavMenu() {
const menuContainer = document.getElementById('chat-nav-menu-container');
const counter = document.getElementById('chat-nav-counter');
if (!menuContainer || !counter) return;
const isVisible = menuContainer.classList.contains('visible');
if (isVisible) {
menuContainer.classList.remove('visible');
const menuList = document.getElementById('chat-nav-menu');
if (menuList) menuList.tabIndex = -1;
counter.setAttribute('aria-expanded', 'false');
document.removeEventListener('click', this.closeNavMenu, true);
ChatController.stopDynamicMenuLoading();
ChatController.focusChatContainer();
} else {
this.populateNavMenu();
const newMenuList = document.getElementById('chat-nav-menu');
if (newMenuList) newMenuList.tabIndex = 0;
const savedWidth = StyleManager._read('quicknav_menu_custom_width');
if (savedWidth) {
menuContainer.style.width = `${savedWidth}px`;
} else {
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%)';
const availableSpace = counterRect.top - 18;
menuContainer.style.maxHeight = `${availableSpace}px`;
this._proactivelySyncMenuScroll();
menuContainer.classList.add('visible');
counter.setAttribute('aria-expanded', 'true');
const items = newMenuList ? newMenuList.querySelectorAll('.chat-nav-menu-item') : [];
const initialFocusIndex = ChatController.currentIndex > -1 ? ChatController.currentIndex : 0;
this.updateMenuFocus(items, initialFocusIndex, false);
if (newMenuList) {
newMenuList.focus();
} else {
menuContainer.focus();
}
setTimeout(() => document.addEventListener('click', this.closeNavMenu, true), 0);
}
},
// Sets up drag-to-resize logic for the menu handles with improved performance and constraints.
setupMenuResizer(handle, direction, container) {
let startX, startWidth, rafId;
const onMouseMove = (e) => {
if (rafId) return;
rafId = requestAnimationFrame(() => {
const currentX = e.clientX;
const deltaX = (currentX - startX) * direction;
let newWidth = startWidth + (deltaX * 2);
const maxWidth = window.innerWidth - 32;
newWidth = Math.max(300, Math.min(newWidth, maxWidth));
container.style.width = `${newWidth}px`;
container.style.maxWidth = '100vw';
rafId = null;
});
};
const onMouseUp = () => {
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.classList.remove('quicknav-resizing');
const currentWidth = parseInt(container.style.width, 10);
if (currentWidth) {
StyleManager._write('quicknav_menu_custom_width', currentWidth);
}
};
handle.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
startX = e.clientX;
startWidth = container.getBoundingClientRect().width;
document.body.style.cursor = 'ew-resize';
document.body.classList.add('quicknav-resizing');
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
},
// Handles clicks outside the menu to close it.
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.classList.contains('visible')) {
this.toggleNavMenu();
}
},
// Proactively sets the scroll position of the (possibly hidden) nav menu.
_proactivelySyncMenuScroll() {
const menuList = document.getElementById('chat-nav-menu');
if (!menuList) return;
const items = menuList.querySelectorAll('.chat-nav-menu-item');
const targetIndex = ChatController.currentIndex;
if (targetIndex < 0 || targetIndex >= items.length) return;
const focusedItem = items[targetIndex];
if (focusedItem) {
menuList.style.scrollBehavior = 'auto';
menuList.scrollTop = focusedItem.offsetTop - (menuList.clientHeight / 2) + (focusedItem.clientHeight / 2);
menuList.style.scrollBehavior = '';
}
},
// Updates the text content of a single menu item if the menu is visible.
updateMenuItemContent(index) {
const menuContainer = document.getElementById('chat-nav-menu-container');
if (!menuContainer || !menuContainer.classList.contains('visible')) return;
const menuList = document.getElementById('chat-nav-menu');
if (!menuList) return;
const menuItem = menuList.children[index];
const turn = ChatController.allTurns[index];
if (!menuItem || !turn || !turn.cachedContent) return;
const textSpan = menuItem.querySelector('.menu-item-text');
if (textSpan) {
const { display, full } = turn.cachedContent;
const truncatedText = (display.length > 200) ? display.substring(0, 197) + '...' : display;
if (textSpan.textContent !== truncatedText) {
textSpan.textContent = truncatedText;
}
menuItem.dataset.tooltip = full.replace(/\s+/g, ' ');
}
},
updateMenuFocus(items, newIndex, shouldScroll = true) {
if (!items || items.length === 0 || newIndex < 0 || newIndex >= items.length) return;
if (ChatController.menuFocusedIndex > -1 && ChatController.menuFocusedIndex < items.length) {
items[ChatController.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;
}
}
}
ChatController.menuFocusedIndex = newIndex;
},
// Extracts text using a hybrid approach: DOM is priority, scrollbar is fallback.
getTextFromTurn(turn, fromDOMOnly = false) {
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 = ` [Code Block] `;
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' };
}
}
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' };
}
}
}
return { display: '...', full: 'Could not extract content.', source: 'fallback' };
},
// Populates the navigation menu, performing an initial cache fill on first run.
populateNavMenu() {
const menuContainer = document.getElementById('chat-nav-menu-container');
if (!menuContainer) return;
Array.from(menuContainer.children).forEach(child => {
if (!child.classList.contains('quicknav-menu-resizer')) {
child.remove();
}
});
const header = document.createElement('div');
header.className = 'chat-nav-menu-header';
const menuList = document.createElement('ul');
menuList.id = 'chat-nav-menu';
menuList.tabIndex = -1;
ChatController.allTurns.forEach((turn, index) => {
let displayContent;
if (turn.cachedContent && !turn.isFallbackContent) {
displayContent = turn.cachedContent;
} else {
displayContent = this.getTextFromTurn(turn);
const isStreaming = !!turn.querySelector('loading-indicator');
if (isStreaming) {
turn.cachedContent = null;
turn.isFallbackContent = true;
} else {
turn.cachedContent = displayContent;
turn.isFallbackContent = displayContent.source === 'fallback';
}
}
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 = ChatController.isUserPrompt(turn);
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.dataset.tooltip = full.replace(/\s+/g, ' ');
item.addEventListener('click', () => {
this.toggleNavMenu();
ChatController.navigateToIndex(index);
});
menuList.appendChild(item);
});
menuList.addEventListener('wheel', (e) => {
const list = e.currentTarget;
const isScrollable = list.scrollHeight > list.clientHeight;
const isScrollingUp = e.deltaY < 0;
const isScrollingDown = e.deltaY > 0;
if (!isScrollable) {
e.preventDefault();
return;
}
const isAtTop = list.scrollTop === 0;
const isAtBottom = Math.ceil(list.scrollTop + list.clientHeight) >= list.scrollHeight;
if ((isAtTop && isScrollingUp) || (isAtBottom && isScrollingDown)) {
e.preventDefault();
}
});
let tooltipTarget = null;
menuList.addEventListener('mousemove', e => {
const currentTarget = e.target.closest('.chat-nav-menu-item');
if (currentTarget && currentTarget === tooltipTarget && this.customTooltip.style.opacity === '1') {
this.positionTooltip(e);
return;
}
if (currentTarget !== tooltipTarget) {
clearTimeout(this.tooltipTimeout);
this.customTooltip.style.opacity = '0';
tooltipTarget = currentTarget;
if (tooltipTarget) {
this.tooltipTimeout = setTimeout(() => {
if (!tooltipTarget) return;
this.customTooltip.textContent = tooltipTarget.dataset.tooltip;
if (tooltipTarget.classList.contains('response-item-bg')) {
const isDarkMode = document.body.classList.contains('dark-theme');
this.customTooltip.style.color = isDarkMode ? 'var(--ms-primary, #8ab4f8)' : '#174ea6';
} else {
this.customTooltip.style.color = '';
}
this.customTooltip.style.opacity = '1';
requestAnimationFrame(() => this.positionTooltip(e));
}, 500);
}
}
});
menuList.addEventListener('mouseleave', () => {
clearTimeout(this.tooltipTimeout);
this.customTooltip.style.opacity = '0';
tooltipTarget = null;
});
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();
ChatController.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 google-text-animated';
titleElement.textContent = 'QuickNav for Google AI Studio';
const rightContainer = document.createElement('div');
rightContainer.className = 'header-controls right';
const donateButton = document.createElement('a');
donateButton.id = 'chat-nav-donate-link';
donateButton.href = 'https://nowpayments.io/donation/axl_script';
donateButton.target = '_blank';
donateButton.rel = 'noopener noreferrer';
donateButton.className = 'header-button';
donateButton.title = 'Support the developer';
const donateText = document.createElement('span');
donateText.className = 'donate-button-animated';
donateText.textContent = 'Donate';
donateButton.appendChild(donateText);
donateButton.addEventListener('click', (e) => e.stopPropagation());
rightContainer.append(donateButton);
header.append(leftContainer, titleElement, rightContainer);
menuContainer.appendChild(header);
menuContainer.appendChild(menuList);
},
// Updates the badge's position and content, ensuring it stays within all UI boundaries.
updateScrollPercentage() {
const floater = document.getElementById('quicknav-badge-floater');
const percentageBadge = document.getElementById('quicknav-badge-percentage');
const scrollContainer = document.querySelector('ms-autoscroll-container');
if (!floater || !percentageBadge || !scrollContainer || ChatController.currentIndex < 0) {
if (floater) this.hideBadge(0, true);
return;
}
const currentTurn = ChatController.allTurns[ChatController.currentIndex];
if (!currentTurn) {
this.hideBadge(0, true);
return;
}
const rect = currentTurn.getBoundingClientRect();
const scrollContainerRect = scrollContainer.getBoundingClientRect();
const turnHeight = rect.height;
const viewportHeight = window.innerHeight;
const MIN_VISIBLE_HEIGHT = 40;
const visibleHeight = Math.max(0, Math.min(rect.bottom, viewportHeight) - Math.max(rect.top, 0));
const isElementVisible = visibleHeight >= MIN_VISIBLE_HEIGHT;
if (floater.style.opacity === '1' && !isElementVisible) {
this.hideBadge(0, true);
return;
}
if (!isElementVisible || turnHeight <= 0) {
return;
}
const floaterHeight = floater.offsetHeight || 44;
const visibleTurnTop = Math.max(rect.top, scrollContainerRect.top);
const visibleTurnBottom = Math.min(rect.bottom, scrollContainerRect.bottom);
const idealTop = (visibleTurnTop + visibleTurnBottom) / 2 - (floaterHeight / 2);
const toolbarBottom = this.toolbarElement ? this.toolbarElement.getBoundingClientRect().bottom : 0;
const footerTop = this.footerElement ? this.footerElement.getBoundingClientRect().top : viewportHeight;
const navContainerTop = this.navContainerElement ? this.navContainerElement.getBoundingClientRect().top : viewportHeight;
const upperBound = Math.max(scrollContainerRect.top + 4, rect.top, toolbarBottom + 4);
const lowerBound = Math.min(scrollContainerRect.bottom, rect.bottom, footerTop, navContainerTop) - floaterHeight - 4;
let finalTop = Math.max(upperBound, Math.min(idealTop, lowerBound));
floater.style.top = `${finalTop}px`;
floater.style.left = `${rect.right - (floater.offsetWidth / 2)}px`;
const viewportCenterY = viewportHeight / 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)}%`;
},
positionTooltip(e) {
const tooltip = this.customTooltip;
if (!tooltip) return;
const winWidth = window.innerWidth;
const winHeight = window.innerHeight;
const tipHeight = tooltip.offsetHeight;
const tipWidth = tooltip.offsetWidth;
const margin = 10;
let top;
if (tipHeight >= winHeight) {
top = 0;
} else {
top = e.clientY + 15;
if (top + tipHeight + margin > winHeight) {
top = winHeight - tipHeight - margin;
}
}
let left = e.clientX + 100;
if (left + tipWidth + margin > winWidth) {
left = winWidth - tipWidth - margin;
}
left = Math.max(margin, left);
tooltip.style.top = `${top}px`;
tooltip.style.left = `${left}px`;
}
};
UIManager.closeNavMenu = UIManager.closeNavMenu.bind(UIManager);
// --- MODULE: Code Block Navigator ---
// Finds code blocks and injects or updates intra-message navigation controls.
const CodeBlockNavigator = {
processTurns(allTurns) {
if (!allTurns) return;
allTurns.forEach(turn => {
const codeBlocksInTurn = Array.from(turn.querySelectorAll('ms-code-block'));
if (codeBlocksInTurn.length === 0) return;
codeBlocksInTurn.forEach((block, index) => {
const header = block.querySelector('mat-expansion-panel-header .mat-content');
const actionsContainer = header ? header.querySelector('.actions-container') : null;
if (!header || !actionsContainer) return;
let navContainer = header.querySelector('.code-block-nav-container');
if (!navContainer) {
navContainer = document.createElement('div');
navContainer.className = 'code-block-nav-container';
const pathUp = 'M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z';
const pathDown = 'M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z';
const btnUp = this._createNavButton(pathUp);
const counter = document.createElement('div');
counter.className = 'code-nav-counter';
const btnDown = this._createNavButton(pathDown);
btnUp.addEventListener('click', (e) => this._handleNavClick(e));
btnDown.addEventListener('click', (e) => this._handleNavClick(e));
navContainer.append(btnUp, counter, btnDown);
actionsContainer.appendChild(navContainer);
}
const [btnUp, counter, btnDown] = navContainer.children;
const totalBlocks = codeBlocksInTurn.length;
const currentBlockNum = index + 1;
const isFirst = index === 0;
const isLast = index === totalBlocks - 1;
counter.textContent = `${currentBlockNum} / ${totalBlocks}`;
counter.title = `Code block ${currentBlockNum} of ${totalBlocks}`;
if (isFirst) {
btnUp.dataset.navTarget = 'header';
btnUp.title = `Scroll to the header of this block (${currentBlockNum}/${totalBlocks})`;
} else {
btnUp.dataset.navTarget = 'previous';
btnUp.title = `Go to previous block (${currentBlockNum - 1}/${totalBlocks})`;
}
if (isLast) {
btnDown.dataset.navTarget = 'footer';
btnDown.title = `Scroll to the footer of this block (${currentBlockNum}/${totalBlocks})`;
} else {
btnDown.dataset.navTarget = 'next';
btnDown.title = `Go to next block (${currentBlockNum + 1}/${totalBlocks})`;
}
});
});
},
_createNavButton(pathData) {
const button = document.createElement('button');
button.className = 'code-nav-button';
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute('height', '16px');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('width', '16px');
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;
},
_handleNavClick(event) {
event.stopPropagation();
event.preventDefault();
const button = event.currentTarget;
const navTarget = button.dataset.navTarget;
const currentCodeBlock = button.closest('ms-code-block');
if (!currentCodeBlock) return;
const parentTurn = currentCodeBlock.closest('ms-chat-turn');
if (!parentTurn) return;
const turnCodeBlocks = Array.from(parentTurn.querySelectorAll('ms-code-block'));
const currentIndex = turnCodeBlocks.indexOf(currentCodeBlock);
let targetBlock = null;
switch (navTarget) {
case 'header':
this._scrollToElement(currentCodeBlock, 'header');
break;
case 'footer':
this._scrollToElement(currentCodeBlock, 'footer');
break;
case 'previous':
if (currentIndex > 0) {
targetBlock = turnCodeBlocks[currentIndex - 1];
this._scrollToElement(targetBlock, 'header');
}
break;
case 'next':
if (currentIndex < turnCodeBlocks.length - 1) {
targetBlock = turnCodeBlocks[currentIndex + 1];
this._scrollToElement(targetBlock, 'header');
}
break;
}
},
_scrollToElement(element, position = 'header') {
if (!element) return;
ChatController.isScrollingProgrammatically = true;
const anchor = document.createElement('div');
let scrollOptions = { behavior: 'smooth', block: 'center' };
let targetNodeForAnchor;
if (position === 'header') {
targetNodeForAnchor = element.querySelector('mat-expansion-panel-header');
if (targetNodeForAnchor) {
anchor.style.cssText = 'position: absolute; height: 0; width: 0; margin-top: -30px;';
targetNodeForAnchor.parentNode.insertBefore(anchor, targetNodeForAnchor);
}
} else {
targetNodeForAnchor = element;
anchor.style.cssText = 'display: block; height: 1px;';
targetNodeForAnchor.appendChild(anchor);
scrollOptions.block = 'end';
}
if (targetNodeForAnchor) {
anchor.scrollIntoView(scrollOptions);
}
setTimeout(() => {
anchor.remove();
ChatController.isScrollingProgrammatically = false;
}, 700);
}
};
// --- MODULE: Hotkeys Manager ---
// Handles global keyboard shortcuts for navigation and styling.
const HotkeysManager = {
pressedKeys: new Set(),
lastStyleChange: 0,
keyMap: {
'Alt_PageUp': '#nav-up',
'Alt_PageDown': '#nav-down',
'Shift_Alt_PageUp': '#nav-top',
'Shift_Alt_PageDown': '#nav-bottom',
'Alt_KeyM': '#chat-nav-counter'
},
init() {
document.addEventListener('keydown', this.handleKeyDown.bind(this));
document.addEventListener('keyup', this.handleKeyUp.bind(this));
window.addEventListener('blur', this.handleBlur.bind(this));
},
getKeyCombination(e) {
let key = '';
if (e.shiftKey) key += 'Shift_';
if (e.altKey) key += 'Alt_';
key += e.code;
return key;
},
// Handles global keyboard events, including new reset shortcuts.
handleKeyDown(e) {
if (e.altKey && e.code === 'KeyP') {
const promptInput = document.querySelector('ms-prompt-box textarea') || document.querySelector('textarea[placeholder="Start typing a prompt"]');
if (promptInput && document.activeElement !== promptInput) {
e.preventDefault();
e.stopPropagation();
ChatController.focusPromptInput();
return;
}
}
if (e.altKey && (e.code === 'Minus' || e.code === 'Equal')) {
e.preventDefault();
e.stopPropagation();
const now = Date.now();
if (now - this.lastStyleChange < 50) return;
this.lastStyleChange = now;
const isShift = e.shiftKey;
const isPlus = e.code === 'Equal';
const type = isShift ? 'WIDTH' : 'FONT';
const config = StyleManager[type];
const step = isPlus ? config.STEP : -config.STEP;
if (!config.enabled) {
StyleManager.toggleSetting(type, true);
}
StyleManager.changeValue(type, step);
return;
}
if (e.altKey && e.code === 'Digit0') {
e.preventDefault();
e.stopPropagation();
const type = e.shiftKey ? 'WIDTH' : 'FONT';
if (!StyleManager[type].enabled) {
StyleManager.toggleSetting(type, true);
}
StyleManager.resetValue(type);
return;
}
const isTypingArea = e.target.isContentEditable || e.target.closest('[contenteditable="true"]') || ['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName);
if (e.repeat || (!e.altKey && isTypingArea)) {
return;
}
const combination = this.getKeyCombination(e);
const targetSelector = this.keyMap[combination];
if (targetSelector) {
e.preventDefault();
e.stopPropagation();
if (!this.pressedKeys.has(combination)) {
this.pressedKeys.add(combination);
const targetElement = document.querySelector(targetSelector);
if (targetElement) {
targetElement.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window, buttons: 1 }));
}
}
}
},
handleKeyUp(e) {
const releasedCombos = [];
this.pressedKeys.forEach(combo => {
const parts = combo.split('_');
const mainKey = parts[parts.length - 1];
let isReleased = false;
if (mainKey === e.code) {
isReleased = true;
} else if ((parts.includes('Shift') && (e.code === 'ShiftLeft' || e.code === 'ShiftRight')) || (parts.includes('Alt') && (e.code === 'AltLeft' || e.code === 'AltRight'))) {
const requiredAlt = parts.includes('Alt');
const requiredShift = parts.includes('Shift');
if ((requiredAlt && !e.altKey) || (requiredShift && !e.shiftKey)) {
isReleased = true;
}
}
if (isReleased) {
releasedCombos.push(combo);
}
});
if (releasedCombos.length > 0) {
e.preventDefault();
e.stopPropagation();
releasedCombos.forEach(combo => {
this.pressedKeys.delete(combo);
const targetSelector = this.keyMap[combo];
if (targetSelector) {
const targetElement = document.querySelector(targetSelector);
if (targetElement) {
targetElement.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window, buttons: 0 }));
targetElement.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window, buttons: 1 }));
}
}
});
}
},
handleBlur() {
this.pressedKeys.forEach(combo => {
const targetSelector = this.keyMap[combo];
if (targetSelector) {
const targetElement = document.querySelector(targetSelector);
if (targetElement) {
targetElement.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window, buttons: 0 }));
}
}
});
this.pressedKeys.clear();
}
};
// Robustly watches for the chat interface using MutationObserver and efficient polling state machine.
const DOMObserver = {
observer: null,
isChatActive: false,
checkInterval: null,
onChatReady: null,
onChatDestroyed: null,
CHAT_CONTAINER_SELECTOR: 'ms-chunk-editor, .chat-container, ms-autoscroll-container',
start(onReady, onDestroyed) {
this.onChatReady = onReady;
this.onChatDestroyed = onDestroyed;
this.observer = new MutationObserver(this.debounce(this.checkState.bind(this), 200));
this.observer.observe(document.body, { childList: true, subtree: true });
this.startPolling();
},
debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
},
startPolling() {
if (this.checkInterval) return;
this.checkState();
this.checkInterval = setInterval(() => {
this.checkState();
}, 1000);
},
stopPolling() {
if (this.checkInterval) {
clearInterval(this.checkInterval);
this.checkInterval = null;
}
},
checkState() {
const chatContainer = document.querySelector(this.CHAT_CONTAINER_SELECTOR);
const areElementsPresent = !!chatContainer;
if (areElementsPresent && !this.isChatActive) {
console.log(`[QuickNav] Chat detected via ${chatContainer.tagName}. UI injected.`);
this.isChatActive = true;
this.stopPolling();
this.onChatReady(chatContainer, chatContainer);
}
else if (!areElementsPresent && this.isChatActive) {
console.log('[QuickNav] Chat elements lost. Destroying UI and restarting search...');
this.isChatActive = false;
this.onChatDestroyed();
this.startPolling();
}
}
};
// --- MAIN APP ORCHESTRATOR ---
// Initializes and coordinates all modules.
const QuickNavApp = {
init() {
DOMObserver.start(
this.handleChatReady.bind(this),
this.handleChatDestroyed.bind(this)
);
HotkeysManager.init();
},
handleChatReady(chatContainer, uiAnchor) {
try {
UIManager.create(uiAnchor);
ChatController.init(chatContainer);
} catch (e) {
console.error('[QuickNav] Error during UI initialization:', e);
}
},
handleChatDestroyed() {
UIManager.destroy();
ChatController.destroy();
}
};
QuickNavApp.init();
})();