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