Google Gemini AI: Add Table of Contents (TOC) to Chats

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.

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