// ==UserScript==
// @name Google Gemini AI: Add Table of Contents (TOC) to Chats
// @namespace Violentmonkey userscripts by ReporterX
// @author ReporterX
// @version 1.0
// @description Add a floating Table of Contents (TOC) to each Google Gemini chat. This would allow users to easily jump to any section of the chat with a single click. The TOC is adjustable, remembering its size and position for a consistent experience across all chats.
// @match https://gemini.google.com/*
// @grant none
// @icon https://www.google.com/s2/favicons?sz=64&domain=gemini.google.com
// @license MIT
// ==/UserScript==
(function () {
"use strict";
// Saves the TOC's current position and size to localStorage.
function saveTOCState(toc) {
const state = {
top: toc.style.top,
left: toc.style.left,
width: toc.style.width,
height: toc.style.height,
};
localStorage.setItem("gemini-toc-state", JSON.stringify(state));
}
// Loads the TOC's saved position and size from localStorage.
function loadTOCState() {
const state = localStorage.getItem("gemini-toc-state");
return state ? JSON.parse(state) : null;
}
// Creates and initializes the main TOC element, along with its interactive components.
function createTOC() {
const toc = document.createElement("div");
toc.id = "gemini-toc";
// Retrieve saved state or set default dimensions and position.
const savedState = loadTOCState();
const initialState = {
top: savedState?.top || "20px",
left: savedState?.left || "auto",
right: savedState?.left ? "auto" : "20px", // Position from the right if no left coordinate is saved.
width: savedState?.width || "250px",
height: savedState?.height || "300px",
};
toc.style.cssText = `
position: fixed;
top: ${initialState.top};
left: ${initialState.left};
right: ${initialState.right};
width: ${initialState.width};
height: ${initialState.height};
background: rgba(255, 255, 255, 0.6);
border: 1px solid #ddd;
border-radius: 6px;
padding: 0;
z-index: 10000;
opacity: 0.3;
transition: opacity 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
flex-direction: column;
`;
// Create the draggable header for the TOC window.
const header = document.createElement("h3");
header.textContent = "TOC";
header.style.cssText = `
margin: 0;
padding: 4px;
font-size: 11px;
color: #666;
text-align: center;
border-bottom: 1px solid #eee;
font-weight: 500;
cursor: move;
background-color: rgba(240, 240, 240, 0.7);
`;
toc.appendChild(header);
// Create the container that will hold the scrollable TOC links.
const content = document.createElement("div");
content.id = "gemini-toc-content";
content.style.cssText = `
overflow-y: auto;
flex-grow: 1;
padding: 8px;
min-width: 0; /* Flexbox fix to allow shrinking */
`;
toc.appendChild(content);
// Make the TOC more visible when the user hovers over it.
toc.addEventListener("mouseenter", () => {
toc.style.opacity = "1.0";
});
toc.addEventListener("mouseleave", () => {
toc.style.opacity = "0.3";
});
document.body.appendChild(toc);
// Initialize the new, unified interaction handler.
makeInteractive(toc, header);
return toc;
}
// A new, single function to handle both dragging and resizing.
function makeInteractive(element, header) {
let action = null;
let startX, startY, startWidth, startHeight, startLeft, startTop;
const minWidth = 150;
const minHeight = 100;
// Determines the resize direction based on mouse position.
function getResizeDirection(e) {
const rect = element.getBoundingClientRect();
const zone = 8;
const onRight = e.clientX > rect.right - zone;
const onLeft = e.clientX < rect.left + zone;
const onBottom = e.clientY > rect.bottom - zone;
if (onRight && onBottom) return 'se';
if (onLeft && onBottom) return 'sw';
if (onRight) return 'e';
if (onLeft) return 'w';
if (onBottom) return 's';
return null;
}
// Handles the initial mousedown event to determine the action.
function onMouseDown(e) {
if (e.button !== 0) return;
const resizeDir = getResizeDirection(e);
if (e.target === header && !resizeDir) {
action = { type: 'drag' };
} else if (resizeDir) {
action = { type: 'resize', dir: resizeDir };
} else {
return;
}
e.preventDefault();
e.stopPropagation();
const rect = element.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
startWidth = rect.width;
startHeight = rect.height;
startLeft = rect.left;
startTop = rect.top;
// Ensure positioning is done via 'left' and 'top'.
element.style.right = 'auto';
element.style.left = `${startLeft}px`;
element.style.top = `${startTop}px`;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
// Handles the mouse movement for dragging or resizing.
function onMouseMove(e) {
if (!action) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (action.type === 'drag') {
element.style.left = `${startLeft + dx}px`;
element.style.top = `${startTop + dy}px`;
} else if (action.type === 'resize') {
if (action.dir.includes('e')) {
element.style.width = `${Math.max(minWidth, startWidth + dx)}px`;
}
if (action.dir.includes('w')) {
const newWidth = Math.max(minWidth, startWidth - dx);
element.style.width = `${newWidth}px`;
element.style.left = `${startLeft + startWidth - newWidth}px`;
}
if (action.dir.includes('s')) {
element.style.height = `${Math.max(minHeight, startHeight + dy)}px`;
}
}
}
// Cleans up event listeners and saves state on mouse up.
function onMouseUp() {
if (action) {
saveTOCState(element);
}
action = null;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
// Updates the cursor style based on mouse position.
function updateCursor(e) {
if (action) return;
const resizeDir = getResizeDirection(e);
element.style.cursor = resizeDir ? `${resizeDir}-resize` : 'default';
header.style.cursor = 'move';
}
element.addEventListener('mousedown', onMouseDown);
element.addEventListener('mousemove', updateCursor);
}
// Scans the page for user prompts to include in the TOC.
function findUserPrompts() {
const prompts = [];
// Prioritize specific selectors that are known to work.
let queryTextElements = document.querySelectorAll("p.query-text-line.ng-star-inserted");
if (queryTextElements.length === 0) {
queryTextElements = document.querySelectorAll('p[class*="query-text"]');
}
if (queryTextElements.length > 0) {
queryTextElements.forEach((element) => {
const text = element.textContent.trim();
if (text) prompts.push({ element, text });
});
return prompts;
}
// Fallback to a broader set of selectors if the primary ones fail.
const selectors = [
'[data-message-author-role="user"]', ".user-message", '[role="user"]',
".message.user", 'div[data-test-id*="user"]', 'div[data-test-id*="prompt"]',
".prompt-content", ".user-input", '[class*="user"][class*="message"]',
];
for (const selector of selectors) {
const elements = document.querySelectorAll(selector);
if (elements.length > 0) {
elements.forEach((element) => {
const text = element.textContent.trim();
if (text) prompts.push({ element, text });
});
if (prompts.length > 0) break;
}
}
return prompts;
}
// Finds user prompts and populates the TOC content.
function updateTOC(tocContainer) {
const contentContainer = tocContainer.querySelector("#gemini-toc-content");
const prompts = findUserPrompts();
// Remove old items before repopulating.
while (contentContainer.firstChild) {
contentContainer.removeChild(contentContainer.firstChild);
}
if (prompts.length === 0) {
const noContent = document.createElement("div");
noContent.textContent = "No chat found";
noContent.style.cssText = `color: #999; font-style: italic; text-align: center; padding: 15px 0;`;
contentContainer.appendChild(noContent);
return;
}
prompts.forEach((prompt, index) => {
const item = document.createElement("div");
item.className = "toc-item";
item.style.cssText = `
padding: 6px 8px;
margin: 2px 0;
background: rgba(240, 240, 240, 0.5);
border-radius: 3px;
cursor: pointer;
transition: background-color 0.2s ease;
font-size: 12px;
line-height: 1.3;
border-left: 2px solid #4285f4;
`;
const itemText = document.createElement("span");
// Use CSS for intelligent text truncation based on the container's width.
itemText.style.cssText = `
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
// Set the full text content; the browser will handle truncating it.
itemText.textContent = `${index + 1}. ${prompt.text}`;
item.appendChild(itemText);
item.addEventListener("mouseenter", () => {
item.style.backgroundColor = "rgba(66, 133, 244, 0.1)";
});
item.addEventListener("mouseleave", () => {
item.style.backgroundColor = "rgba(240, 240, 240, 0.5)";
});
item.addEventListener("click", () => {
prompt.element.scrollIntoView({ behavior: "smooth", block: "center" });
const originalBg = prompt.element.style.backgroundColor;
prompt.element.style.transition = "background-color 0.5s ease";
prompt.element.style.backgroundColor = "rgba(66, 133, 244, 0.2)";
setTimeout(() => {
prompt.element.style.backgroundColor = originalBg;
}, 2000);
});
contentContainer.appendChild(item);
});
}
// Main initialization function.
function init() {
// Wait for the DOM to be fully loaded before running.
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
return;
}
// Create the main TOC container.
const tocContainer = createTOC();
// A short delay to allow the Gemini UI to load before the first TOC update.
setTimeout(() => {
updateTOC(tocContainer);
}, 1000);
// Use a MutationObserver to detect when the conversation content changes.
const observer = new MutationObserver(() => {
// Debounce the update function to avoid excessive calls during rapid DOM changes.
clearTimeout(window.tocUpdateTimeout);
window.tocUpdateTimeout = setTimeout(() => {
updateTOC(tocContainer);
}, 500);
});
// Observe changes to the entire document body to catch all relevant updates.
observer.observe(document.body, { childList: true, subtree: true });
}
// Start the script.
init();
})();