ChatGPT Overview

Floating outline modal for ChatGPT conversations

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         ChatGPT Overview
// @namespace    http://tampermonkey.net/
// @version      2026-01-15
// @description  Floating outline modal for ChatGPT conversations
// @author       stark-bit
// @license      GPL-3.0
// @match        https://www.chatgpt.com/c/*
// @match        https://chatgpt.com/c/*
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  const STORAGE_KEY = 'chatgpt-overview-minimized';
  const STORAGE_KEY_COLLAPSED = 'chatgpt-overview-collapsed';

  // Cache for extracted article data (prevents re-extraction when articles are virtualized)
  const entryCache = new Map();
  let lastArticleCount = 0;
  let llmCollapsed = false;
  const visibleArticles = new Set(); // Track which article indices are currently in view
  let initialScrollDone = false; // Track if initial scroll to bottom has been done
  let isAtChatEnd = false; // Track if user is at the end of chat

  // --- Utility Functions ---
  function truncate(str, maxLen) {
    if (!str) return '';
    str = str.replace(/\s+/g, ' ').trim();
    return str.length > maxLen ? str.slice(0, maxLen) + '...' : str;
  }

  function debounce(fn, delay) {
    let timeout;
    return (...args) => {
      clearTimeout(timeout);
      timeout = setTimeout(() => fn(...args), delay);
    };
  }

  function throttle(fn, limit) {
    let inThrottle = false;
    return (...args) => {
      if (!inThrottle) {
        fn(...args);
        inThrottle = true;
        setTimeout(() => inThrottle = false, limit);
      }
    };
  }

  function isDarkMode() {
    return document.documentElement.classList.contains('dark') ||
           document.body.classList.contains('dark') ||
           window.matchMedia('(prefers-color-scheme: dark)').matches;
  }

  function getMinimizedState() {
    return localStorage.getItem(STORAGE_KEY) === 'true';
  }

  function setMinimizedState(value) {
    localStorage.setItem(STORAGE_KEY, value);
  }

  function getCollapsedState() {
    return localStorage.getItem(STORAGE_KEY_COLLAPSED) === 'true';
  }

  function setCollapsedState(value) {
    localStorage.setItem(STORAGE_KEY_COLLAPSED, value);
  }

  // --- CSS Injection ---
  function injectStyles() {
    const styles = document.createElement('style');
    styles.id = 'chat-overview-styles';
    styles.textContent = `
      #chat-overview-container {
        position: fixed;
        top: 70px;
        right: 20px;
        width: 300px;
        min-height: 100px;
        max-height: 60vh;
        z-index: 10000;
        border-radius: 8px;
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
        font-size: 13px;
        display: flex;
        flex-direction: column;
        transition: width 0.2s ease, min-height 0.2s ease, max-height 0.2s ease, border-radius 0.2s ease;
        overflow: hidden;
        contain: layout style;
        will-change: width, min-height, max-height;
      }

      #chat-overview-container.minimized {
        width: 50px;
        min-height: 50px;
        max-height: 50px;
        border-radius: 8px;
      }

      /* Light theme */
      #chat-overview-container.light {
        background: rgba(255, 255, 255, 0.95);
        border: 1px solid rgba(0, 0, 0, 0.1);
        color: #1a1a1a;
      }

      /* Dark theme */
      #chat-overview-container.dark {
        background: rgba(32, 33, 35, 0.95);
        border: 1px solid rgba(255, 255, 255, 0.1);
        color: #e5e5e5;
      }

      #chat-overview-header {
        display: flex;
        align-items: center;
        justify-content: space-between;
        padding: 10px 12px;
        border-bottom: 1px solid;
        flex-shrink: 0;
      }

      #chat-overview-header-controls {
        display: flex;
        align-items: center;
        gap: 4px;
      }

      #chat-overview-container.minimized #chat-overview-header-controls {
        justify-content: center;
      }

      #chat-overview-container.light #chat-overview-header {
        border-bottom-color: rgba(0, 0, 0, 0.08);
      }

      #chat-overview-container.dark #chat-overview-header {
        border-bottom-color: rgba(255, 255, 255, 0.08);
      }

      #chat-overview-container.minimized #chat-overview-header {
        border-bottom: none;
        padding: 0;
        justify-content: center;
        height: 50px;
      }

      #chat-overview-title {
        font-weight: 500;
        font-size: 12px;
        text-transform: uppercase;
        letter-spacing: 0.5px;
        opacity: 0.7;
      }

      #chat-overview-container.minimized #chat-overview-title {
        display: none;
      }

      #chat-overview-toggle {
        background: none;
        border: none;
        cursor: pointer;
        font-size: 22px;
        padding: 6px;
        border-radius: 6px;
        opacity: 0.6;
        transition: opacity 0.15s ease, background 0.15s ease;
        line-height: 1;
        display: flex;
        align-items: center;
        justify-content: center;
      }

      #chat-overview-collapse {
        background: none;
        border: none;
        cursor: pointer;
        font-size: 17px;
        padding: 6px;
        border-radius: 6px;
        opacity: 0.6;
        transition: opacity 0.15s ease, background 0.15s ease;
        line-height: 1;
        display: flex;
        align-items: center;
        justify-content: center;
      }

      #chat-overview-container.minimized #chat-overview-collapse {
        display: none;
      }

      #chat-overview-toggle:hover,
      #chat-overview-collapse:hover {
        opacity: 1;
      }

      #chat-overview-container.light #chat-overview-toggle:hover,
      #chat-overview-container.light #chat-overview-collapse:hover {
        background: rgba(0, 0, 0, 0.05);
      }

      #chat-overview-container.dark #chat-overview-toggle:hover,
      #chat-overview-container.dark #chat-overview-collapse:hover {
        background: rgba(255, 255, 255, 0.1);
      }

      #chat-overview-content {
        flex: 1;
        overflow-y: auto;
        padding: 8px 0;
      }

      #chat-overview-container.minimized #chat-overview-content {
        display: none;
      }

      #chat-overview-content::-webkit-scrollbar {
        width: 6px;
      }

      #chat-overview-content::-webkit-scrollbar-track {
        background: transparent;
      }

      #chat-overview-container.light #chat-overview-content::-webkit-scrollbar-thumb {
        background: rgba(0, 0, 0, 0.2);
        border-radius: 3px;
      }

      #chat-overview-container.dark #chat-overview-content::-webkit-scrollbar-thumb {
        background: rgba(255, 255, 255, 0.2);
        border-radius: 3px;
      }

      .chat-overview-entry {
        padding: 6px 12px;
      }

      .chat-overview-user {
        padding-left: 16px;
        border-left: 3px solid #6b8aad;
        margin-left: 12px;
        cursor: pointer;
        transition: background 0.1s ease;
        border-radius: 0 4px 4px 0;
      }

      #chat-overview-container.light .chat-overview-user:hover {
        background: rgba(107, 138, 173, 0.1);
      }

      #chat-overview-container.dark .chat-overview-user:hover {
        background: rgba(107, 138, 173, 0.2);
      }

      .chat-overview-user-text {
        display: block;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }

      #chat-overview-container.light .chat-overview-user-text {
        color: #1a1a1a;
      }

      #chat-overview-container.dark .chat-overview-user-text {
        color: #e5e5e5;
      }

      .chat-overview-llm {
        margin-left: 24px;
        padding-left: 12px;
        border-left: 2px solid #7a9a7a;
        font-size: 12px;
      }

      #chat-overview-container.light .chat-overview-llm {
        color: #4a4a4a;
      }

      #chat-overview-container.dark .chat-overview-llm {
        color: #b8b8b8;
      }

      .chat-overview-llm-line {
        display: flex;
        align-items: center;
        padding: 3px 8px;
        cursor: pointer;
        border-radius: 4px;
        transition: background 0.1s ease;
      }

      #chat-overview-container.light .chat-overview-llm-line:hover {
        background: rgba(122, 154, 122, 0.1);
      }

      #chat-overview-container.dark .chat-overview-llm-line:hover {
        background: rgba(122, 154, 122, 0.2);
      }

      .chat-overview-llm-prefix {
        flex-shrink: 0;
        margin-right: 6px;
        opacity: 0.5;
      }

      .chat-overview-llm-text {
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        flex: 1;
      }

      #chat-overview-container.light .chat-overview-llm-text {
        color: #4a4a4a;
      }

      #chat-overview-container.dark .chat-overview-llm-text {
        color: #b8b8b8;
      }

      .chat-overview-code-prefix {
        font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
        font-size: 10px;
        background: rgba(128, 128, 128, 0.2);
        padding: 1px 4px;
        border-radius: 3px;
        margin-right: 6px;
        flex-shrink: 0;
      }

      .chat-overview-empty {
        padding: 20px 12px;
        text-align: center;
        opacity: 0.5;
        font-size: 12px;
      }

      #chat-overview-container.llm-collapsed .chat-overview-llm {
        display: none;
      }

      #chat-overview-container.llm-collapsed .chat-overview-user {
        margin-bottom: 4px;
      }

      /* In-view highlight */
      #chat-overview-container.light .chat-overview-user.in-view {
        background: rgba(107, 138, 173, 0.12);
      }

      #chat-overview-container.dark .chat-overview-user.in-view {
        background: rgba(107, 138, 173, 0.18);
      }

      #chat-overview-container.light .chat-overview-llm.in-view {
        background: rgba(122, 154, 122, 0.1);
        border-radius: 4px;
        margin-right: 8px;
      }

      #chat-overview-container.dark .chat-overview-llm.in-view {
        background: rgba(122, 154, 122, 0.15);
        border-radius: 4px;
        margin-right: 8px;
      }

      /* End of chat entry */
      .chat-overview-end {
        padding: 10px 12px;
        text-align: center;
        cursor: pointer;
        transition: background 0.1s ease, opacity 0.1s ease;
        border-radius: 4px;
        margin: 8px 12px 4px 12px;
        border-top: 1px solid rgba(128, 128, 128, 0.2);
      }

      .chat-overview-end-text {
        font-size: 11px;
        opacity: 0.6;
        text-transform: uppercase;
        letter-spacing: 0.5px;
      }

      #chat-overview-container.light .chat-overview-end:hover {
        background: rgba(0, 0, 0, 0.05);
      }

      #chat-overview-container.dark .chat-overview-end:hover {
        background: rgba(255, 255, 255, 0.08);
      }

      .chat-overview-end:hover .chat-overview-end-text {
        opacity: 0.8;
      }

      /* End entry in-view highlight */
      #chat-overview-container.light .chat-overview-end.in-view {
        background: rgba(128, 128, 128, 0.1);
      }

      #chat-overview-container.dark .chat-overview-end.in-view {
        background: rgba(255, 255, 255, 0.1);
      }

      .chat-overview-end.in-view .chat-overview-end-text {
        opacity: 0.7;
      }
    `;
    document.head.appendChild(styles);
  }

  // --- Modal Creation ---
  function createModal() {
    const container = document.createElement('div');
    container.id = 'chat-overview-container';

    container.innerHTML = `
      <div id="chat-overview-header">
        <span id="chat-overview-title">Outline</span>
        <div id="chat-overview-header-controls">
          <button id="chat-overview-collapse" title="Toggle LLM replies">&#9776;</button>
          <button id="chat-overview-toggle" title="Toggle overview">&#9881;</button>
        </div>
      </div>
      <div id="chat-overview-content"></div>
    `;

    document.body.appendChild(container);

    // Set initial theme
    updateTheme();

    // Set initial minimized state
    if (getMinimizedState()) {
      container.classList.add('minimized');
    }

    // Set initial collapsed state
    llmCollapsed = getCollapsedState();
    if (llmCollapsed) {
      container.classList.add('llm-collapsed');
    }

    // Toggle handler (gear icon - minimize)
    const toggleBtn = container.querySelector('#chat-overview-toggle');
    toggleBtn.addEventListener('click', () => {
      const isMinimized = container.classList.toggle('minimized');
      setMinimizedState(isMinimized);
    });

    // Collapse handler (hamburger icon - collapse LLM entries)
    const collapseBtn = container.querySelector('#chat-overview-collapse');
    collapseBtn.addEventListener('click', () => {
      llmCollapsed = !llmCollapsed;
      container.classList.toggle('llm-collapsed', llmCollapsed);
      setCollapsedState(llmCollapsed);

      // Scroll modal to first highlighted (in-view) entry
      scrollModalToHighlighted();
    });

    return container;
  }

  // --- Theme Update ---
  function updateTheme() {
    const container = document.getElementById('chat-overview-container');
    if (!container) return;

    const dark = isDarkMode();
    container.classList.remove('light', 'dark');
    container.classList.add(dark ? 'dark' : 'light');
  }

  // --- Content Extraction & Rendering ---
  function extractUserEntry(article) {
    const userNode = article.querySelector('[data-message-author-role="user"]');
    if (!userNode) return null;

    const text = truncate(userNode.innerText, 50);
    return {
      type: 'user',
      text: text || '',
      target: article,
      complete: !!text
    };
  }

  function extractLLMEntry(article) {
    const nodes = article.querySelectorAll('p[data-start], h1[data-start], h2[data-start], h3[data-start], h4[data-start], h5[data-start], h6[data-start], pre[data-start]');
    const lines = [];

    for (let i = 0; i < Math.min(2, nodes.length); i++) {
      const node = nodes[i];
      const isCode = node.tagName === 'PRE' || !!node.querySelector('code');
      const text = truncate(node.innerText, 40);
      lines.push({
        text: text || '',
        isCode: isCode,
        target: node
      });
    }

    // Check if we got meaningful content
    const hasContent = lines.some(line => line.text);

    return {
      type: 'llm',
      lines: lines,
      target: article,
      complete: hasContent
    };
  }

  function scanArticles() {
    const articles = document.querySelectorAll('article');
    const entries = [];

    articles.forEach((article, index) => {
      const cacheKey = index;
      const cached = entryCache.get(cacheKey);

      // Determine if this is a user or LLM article
      const isUserArticle = !!article.querySelector('[data-message-author-role="user"]');

      // If we have a complete cached entry, use it (just update target refs)
      if (cached && cached.complete) {
        cached.target = article;
        if (cached.type === 'llm' && cached.lines) {
          const nodes = article.querySelectorAll('p[data-start], h1[data-start], h2[data-start], h3[data-start], h4[data-start], h5[data-start], h6[data-start], pre[data-start]');
          cached.lines.forEach((line, i) => {
            line.target = nodes[i] || article;
          });
        }
        entries.push(cached);
        return;
      }

      // Extract fresh data
      let entry;
      if (isUserArticle) {
        entry = extractUserEntry(article);
      } else {
        entry = extractLLMEntry(article);
      }

      if (entry) {
        // Cache it (even if incomplete, so we know the type)
        entryCache.set(cacheKey, entry);
        entries.push(entry);
      }
    });

    return entries;
  }

  function renderOverview() {
    const content = document.getElementById('chat-overview-content');
    if (!content) return;

    const entries = scanArticles();

    if (entries.length === 0) {
      content.innerHTML = '<div class="chat-overview-empty">No messages yet</div>';
      return;
    }

    content.innerHTML = '';

    let articleIndex = 0;
    entries.forEach(entry => {
      const currentIndex = articleIndex;
      const isVisible = visibleArticles.has(currentIndex);

      if (entry.type === 'user') {
        const userDiv = document.createElement('div');
        userDiv.className = 'chat-overview-entry chat-overview-user';
        if (isVisible) userDiv.classList.add('in-view');
        userDiv.dataset.articleIndex = currentIndex;
        userDiv.innerHTML = `<span class="chat-overview-user-text">${escapeHtml(entry.text)}</span>`;
        userDiv.addEventListener('click', () => {
          entry.target.scrollIntoView({ behavior: 'smooth', block: 'start' });
        });
        content.appendChild(userDiv);
        articleIndex++;
      } else if (entry.type === 'llm') {
        const llmDiv = document.createElement('div');
        llmDiv.className = 'chat-overview-entry chat-overview-llm';
        if (isVisible) llmDiv.classList.add('in-view');
        llmDiv.dataset.articleIndex = currentIndex;

        entry.lines.forEach((line, idx) => {
          const lineDiv = document.createElement('div');
          lineDiv.className = 'chat-overview-llm-line';

          const prefix = idx === 0 ? '├─' : '└─';

          if (line.isCode) {
            lineDiv.innerHTML = `
              <span class="chat-overview-llm-prefix">${prefix}</span>
              <span class="chat-overview-code-prefix">[code]</span>
              <span class="chat-overview-llm-text">${escapeHtml(line.text)}</span>
            `;
          } else {
            lineDiv.innerHTML = `
              <span class="chat-overview-llm-prefix">${prefix}</span>
              <span class="chat-overview-llm-text">${escapeHtml(line.text)}</span>
            `;
          }

          lineDiv.addEventListener('click', () => {
            (line.target || entry.target).scrollIntoView({ behavior: 'smooth', block: 'start' });
          });

          llmDiv.appendChild(lineDiv);
        });

        content.appendChild(llmDiv);
        articleIndex++;
      }
    });

    // Add "Jump to end" entry at the bottom
    const endDiv = document.createElement('div');
    endDiv.className = 'chat-overview-entry chat-overview-end';
    endDiv.id = 'chat-overview-end-entry';
    endDiv.innerHTML = `<span class="chat-overview-end-text">↓ Jump to end</span>`;
    endDiv.addEventListener('click', () => {
      // Scroll to bottom of chat
      const lastArticle = document.querySelector('article:last-of-type');
      if (lastArticle) {
        lastArticle.scrollIntoView({ behavior: 'smooth', block: 'end' });
      } else {
        // Fallback: scroll main container to bottom
        const main = document.querySelector('main');
        if (main) main.scrollTop = main.scrollHeight;
      }
    });
    content.appendChild(endDiv);

    // Scroll modal to bottom on initial load (chat starts at bottom)
    if (!initialScrollDone) {
      content.scrollTop = content.scrollHeight;
      initialScrollDone = true;
    }
  }

  function escapeHtml(str) {
    const div = document.createElement('div');
    div.textContent = str;
    return div.innerHTML;
  }

  // Check all currently visible articles and re-extract incomplete entries
  function checkVisibleArticles() {
    const articles = document.querySelectorAll('article');
    let needsRerender = false;
    const newVisibleSet = new Set();

    articles.forEach((article, index) => {
      // Check if article is in viewport
      const rect = article.getBoundingClientRect();
      const isVisible = rect.top < window.innerHeight && rect.bottom > 0;

      if (isVisible) {
        newVisibleSet.add(index);

        const cached = entryCache.get(index);
        if (cached && !cached.complete) {
          // Re-extract based on type
          const fresh = cached.type === 'user'
            ? extractUserEntry(article)
            : extractLLMEntry(article);

          if (fresh && fresh.complete) {
            entryCache.set(index, fresh);
            needsRerender = true;
          }
        }
      }
    });

    // Check if user is at the end of chat (last article is visible and near bottom)
    const lastArticle = articles[articles.length - 1];
    if (lastArticle) {
      const rect = lastArticle.getBoundingClientRect();
      // Consider "at end" if the bottom of last article is visible
      isAtChatEnd = rect.bottom <= window.innerHeight + 100;
    }

    // Check if visibility changed
    const visibilityChanged = newVisibleSet.size !== visibleArticles.size ||
      [...newVisibleSet].some(idx => !visibleArticles.has(idx));

    // Update the visible set
    visibleArticles.clear();
    newVisibleSet.forEach(idx => visibleArticles.add(idx));

    if (needsRerender) {
      renderOverview();
    } else if (visibilityChanged) {
      // Just update the in-view classes without full re-render
      updateInViewClasses();
    }
  }

  // Update in-view classes without full re-render
  function updateInViewClasses() {
    const content = document.getElementById('chat-overview-content');
    if (!content) return;

    content.querySelectorAll('[data-article-index]').forEach(el => {
      const index = parseInt(el.dataset.articleIndex, 10);
      if (visibleArticles.has(index)) {
        el.classList.add('in-view');
      } else {
        el.classList.remove('in-view');
      }
    });

    // Update "Jump to end" entry highlight
    const endEntry = document.getElementById('chat-overview-end-entry');
    if (endEntry) {
      if (isAtChatEnd) {
        endEntry.classList.add('in-view');
      } else {
        endEntry.classList.remove('in-view');
      }
    }

    // Ensure highlighted entries are visible in modal
    ensureHighlightedVisible();
  }

  // Scroll the modal content to the first highlighted entry
  function scrollModalToHighlighted() {
    const content = document.getElementById('chat-overview-content');
    if (!content) return;

    // Find first in-view entry (prioritize user entries when collapsed)
    const firstHighlighted = content.querySelector('.chat-overview-user.in-view') ||
                             content.querySelector('.in-view');

    if (firstHighlighted) {
      // Use setTimeout to allow CSS transition to complete first
      setTimeout(() => {
        firstHighlighted.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
      }, 50);
    }
  }

  // Ensure highlighted entries are visible within the modal's scroll area
  function ensureHighlightedVisible() {
    const content = document.getElementById('chat-overview-content');
    if (!content) return;

    const contentRect = content.getBoundingClientRect();
    const buffer = 10; // Buffer zone to trigger scroll slightly earlier

    // Get all highlighted entries
    const highlighted = content.querySelectorAll('.in-view');
    if (highlighted.length === 0) return;

    // Find the first highlighted entry that is above the visible area
    for (const el of highlighted) {
      const elRect = el.getBoundingClientRect();
      const isAbove = elRect.bottom < contentRect.top + buffer;

      if (isAbove) {
        // This entry is scrolled out above - scroll it into view
        el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
        return;
      }
    }

    // Find the last highlighted entry that is below the visible area
    for (let i = highlighted.length - 1; i >= 0; i--) {
      const el = highlighted[i];
      const elRect = el.getBoundingClientRect();
      const isBelow = elRect.top > contentRect.bottom - buffer;

      if (isBelow) {
        // This entry is scrolled out below - scroll it into view
        el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
        return;
      }
    }
  }

  // --- Initialization ---
  function init() {
    // Inject styles
    injectStyles();

    // Create modal
    createModal();

    // Initial render
    renderOverview();

    // Check visible articles after a short delay to catch any that were already in view
    // Run multiple times to handle slow-loading content
    setTimeout(checkVisibleArticles, 300);
    setTimeout(checkVisibleArticles, 800);
    setTimeout(checkVisibleArticles, 1500);

    // Watch for DOM changes (new messages) - only re-render when article count changes
    const debouncedCheck = debounce(() => {
      const currentCount = document.querySelectorAll('article').length;
      if (currentCount !== lastArticleCount) {
        // New articles added - clear cache entries beyond previous count
        // to allow fresh extraction of new articles
        for (let i = lastArticleCount; i < currentCount; i++) {
          entryCache.delete(i);
        }
        lastArticleCount = currentCount;
        renderOverview();
        // Re-observe new articles for visibility
        observeArticles();
        // Also check visible articles after new content loads
        setTimeout(checkVisibleArticles, 300);
      }
      updateTheme();
    }, 300);

    // Find the main conversation container to observe
    const findConversationContainer = () => {
      return document.querySelector('main') ||
             document.querySelector('[role="main"]') ||
             document.body;
    };

    const mutationObserver = new MutationObserver(debouncedCheck);

    // Start observing
    const container = findConversationContainer();
    mutationObserver.observe(container, {
      childList: true,
      subtree: true
    });

    // Initial article count
    lastArticleCount = document.querySelectorAll('article').length;

    // IntersectionObserver to retry extraction when articles become visible
    const intersectionObserver = new IntersectionObserver((entries) => {
      let needsRerender = false;

      entries.forEach(ioEntry => {
        if (ioEntry.isIntersecting) {
          const article = ioEntry.target;
          const articles = Array.from(document.querySelectorAll('article'));
          const index = articles.indexOf(article);

          if (index !== -1) {
            const cached = entryCache.get(index);
            // If cached entry is incomplete, try to re-extract
            if (cached && !cached.complete) {
              const isUserArticle = cached.type === 'user';

              if (isUserArticle) {
                const fresh = extractUserEntry(article);
                if (fresh && fresh.complete) {
                  entryCache.set(index, fresh);
                  needsRerender = true;
                }
              } else {
                const fresh = extractLLMEntry(article);
                if (fresh && fresh.complete) {
                  entryCache.set(index, fresh);
                  needsRerender = true;
                }
              }
            }
          }
        }
      });

      if (needsRerender) {
        renderOverview();
      }
    }, { threshold: 0.1 });

    // Function to observe all articles
    function observeArticles() {
      document.querySelectorAll('article').forEach(article => {
        intersectionObserver.observe(article);
      });
    }

    // Initial observation
    observeArticles();

    // Scroll listener to check visibility and re-extract incomplete entries
    const throttledScrollCheck = throttle(checkVisibleArticles, 50);

    // Listen on capturing phase to catch scroll events on any scrollable container
    window.addEventListener('scroll', throttledScrollCheck, true);

    // Also listen for theme changes via media query
    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme);
  }

  // Wait for DOM to be ready
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();