Perplexity Thread ToC Sidebar

Adds a toggleable floating Table of Contents sidebar showing interleaved prompts and AI responses in a Perplexity thread

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name         Perplexity Thread ToC Sidebar
// @version      2.3.0
// @description  Adds a toggleable floating Table of Contents sidebar showing interleaved prompts and AI responses in a Perplexity thread
// @author       Sergio Dias
// @match        *://www.perplexity.ai/*
// @grant        none
// @run-at       document-idle
// @license      MIT
// @namespace https://github.com/sergiodias/sergios-userscripts
// ==/UserScript==

(function () {
  'use strict';

  const TRUNCATE_LEN = 200;
  const STORAGE_KEY_MODE = 'ptoc-display-mode';
  const DEBOUNCE_MS = 300;
  const RETRY_INTERVAL_MS = 500;
  const RETRY_MAX = 20;
  const SIDEBAR_MIN_WIDTH = 280;
  const SIDEBAR_GAP = 16;
  const STORAGE_KEY_AUTOLOAD = 'ptoc-autoload';
  const AUTOLOAD_SCROLL_DELAY = 250;
  const AUTOLOAD_SCROLL_FACTOR = 0.8;

  // ── CSS ──────────────────────────────────────────────────────────────────

  const CSS = `
    #ptoc-toggle {
      position: fixed;
      top: 80px;
      right: 20px;
      z-index: 99999;
      width: 36px;
      height: 36px;
      border-radius: 50%;
      background: oklch(20.09% 0.003 67.68);
      color: oklch(87.35% 0.002 67.8);
      border: 1px solid #3a3a3a;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 18px;
      box-shadow: 0 2px 8px rgba(0,0,0,0.4);
      transition: background 0.15s;
    }
    #ptoc-toggle:hover {
      background: #2e2e2e;
    }
    #ptoc-sidebar {
      position: fixed;
      top: 128px;
      right: 20px;
      min-width: 280px;
      max-height: calc(100vh - 128px - 20px);
      z-index: 99998;
      background: oklch(20.09% 0.003 67.68);
      color: oklch(87.35% 0.002 67.8);
      box-shadow: 0 4px 16px rgba(0,0,0,0.25);
      border: 1px solid #3a3a3a;
      border-radius: 12px;
      display: flex;
      flex-direction: column;
      overflow: hidden;
      font-family: system-ui, sans-serif;
      font-size: 15px;
      transition: opacity 0.15s ease, transform 0.15s ease;
    }
    #ptoc-sidebar.ptoc-hidden {
      opacity: 0;
      transform: translateY(-8px);
      pointer-events: none;
    }
    #ptoc-header {
      padding: 12px 16px 10px;
      border-radius: 12px 12px 0 0;
      font-weight: 600;
      font-size: 12px;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      color: #888;
      border-bottom: 1px solid #2e2e2e;
      flex-shrink: 0;
      display: flex;
      align-items: center;
      justify-content: space-between;
    }
    #ptoc-mode {
      cursor: pointer;
      font-size: 11px;
      padding: 2px 8px;
      border-radius: 4px;
      background: #2e2e2e;
      color: #aaa;
      text-transform: uppercase;
      letter-spacing: 0.05em;
      user-select: none;
      transition: background 0.15s;
    }
    #ptoc-mode:hover {
      background: #3a3a3a;
    }
    #ptoc-header-controls {
      display: flex;
      gap: 6px;
      align-items: center;
    }
    #ptoc-autoload {
      cursor: pointer;
      font-size: 11px;
      padding: 2px 8px;
      border-radius: 4px;
      background: #2e2e2e;
      color: #aaa;
      text-transform: uppercase;
      letter-spacing: 0.05em;
      user-select: none;
      transition: background 0.15s, box-shadow 0.15s;
    }
    #ptoc-autoload:hover {
      background: #3a3a3a;
    }
    #ptoc-autoload.ptoc-on {
      background: #252525;
      color: #ccc;
      box-shadow: inset 0 1px 3px rgba(0,0,0,0.4);
    }
    @keyframes ptoc-spin {
      0%   { transform: rotate(0deg); }
      100% { transform: rotate(360deg); }
    }
    .ptoc-spinner {
      display: inline-block;
      width: 10px;
      height: 10px;
      vertical-align: middle;
      border: 2px solid transparent;
      border-top-color: #aaa;
      border-radius: 50%;
      animation: ptoc-spin 0.6s linear infinite;
    }
    #ptoc-list {
      overflow-y: auto;
      flex: 1;
      padding: 8px 0;
      list-style: none;
      margin: 0;
    }
    #ptoc-list::-webkit-scrollbar {
      width: 4px;
    }
    #ptoc-list::-webkit-scrollbar-thumb {
      background: #3a3a3a;
      border-radius: 2px;
    }
    .ptoc-entry {
      display: flex;
      align-items: flex-start;
      gap: 8px;
      padding: 6px 16px;
      cursor: pointer;
      transition: background 0.1s;
    }
    .ptoc-entry:hover {
      background: #2e2e2e;
    }
    .ptoc-entry.ptoc-active {
      background: #3a3a3a;
    }
    .ptoc-num {
      color: #666;
      min-width: 20px;
      flex-shrink: 0;
      padding-top: 2px;
    }
    .ptoc-body {
      flex: 1;
      min-width: 0;
      overflow: hidden;
    }
    .ptoc-prompt {
      display: -webkit-box;
      -webkit-line-clamp: 2;
      -webkit-box-orient: vertical;
      overflow: hidden;
      color: oklch(87.35% 0.002 67.8);
      font-size: 14px;
      line-height: 1.3;
    }
    .ptoc-response {
      display: -webkit-box;
      -webkit-line-clamp: 2;
      -webkit-box-orient: vertical;
      overflow: hidden;
      color: #a0a0a0;
      font-style: italic;
      font-size: 12px;
      line-height: 1.3;
    }
    .ptoc-skeleton {
      height: 12px;
      width: 60%;
      border-radius: 4px;
      background: #2e2e2e;
      animation: ptoc-pulse 1.5s ease-in-out infinite;
      margin-top: 2px;
    }
    @keyframes ptoc-pulse {
      0%, 100% { opacity: 0.4; }
      50% { opacity: 0.8; }
    }
    .ptoc-skipped {
      opacity: 0.6;
    }
    #ptoc-empty {
      padding: 16px;
      color: #666;
      font-style: italic;
    }
  `;

  // ── State ─────────────────────────────────────────────────────────────────

  let sidebar, list, toggleBtn, modeBtn;
  let activeElements = []; // parallel array: DOM nodes for scroll targets (may be null for virtualized items)
  let cachedPairs = [];    // stable text-only cache; only grows within a navigation
  let displayMode = localStorage.getItem(STORAGE_KEY_MODE) || 'both'; // 'both' | 'prompts' | 'responses'
  let debounceTimer = null;
  let mutationObs = null;
  let intersectionObs = null;
  let intersectionPaused = false;
  let userHidden = false;
  let currentHref = location.href;
  let autoLoadEnabled = localStorage.getItem(STORAGE_KEY_AUTOLOAD) === 'true';
  let autoLoadAborted = false;
  let autoLoadRunning = false;
  let autoLoadBtn = null;

  // ── Init ──────────────────────────────────────────────────────────────────

  function injectStyles() {
    const style = document.createElement('style');
    style.textContent = CSS;
    document.head.appendChild(style);
  }

  function buildUI() {
    // Toggle button
    toggleBtn = document.createElement('button');
    toggleBtn.id = 'ptoc-toggle';
    toggleBtn.title = 'Toggle Table of Contents';
    toggleBtn.innerHTML = '☰'; // ☰
    toggleBtn.style.visibility = 'hidden'; // hidden until scan() finds pairs
    toggleBtn.addEventListener('click', () => {
      userHidden = !userHidden;
      sidebar.classList.toggle('ptoc-hidden', userHidden);
    });

    // Sidebar
    sidebar = document.createElement('div');
    sidebar.id = 'ptoc-sidebar';
    sidebar.classList.add('ptoc-hidden');

    const header = document.createElement('div');
    header.id = 'ptoc-header';

    const titleSpan = document.createElement('span');
    titleSpan.textContent = 'Thread';

    const MODE_LABELS = { both: 'Q+A', prompts: 'Q', responses: 'A' };
    const MODES = ['both', 'prompts', 'responses'];
    modeBtn = document.createElement('span');
    modeBtn.id = 'ptoc-mode';
    modeBtn.textContent = MODE_LABELS[displayMode];
    modeBtn.addEventListener('click', () => {
      displayMode = MODES[(MODES.indexOf(displayMode) + 1) % MODES.length];
      modeBtn.textContent = MODE_LABELS[displayMode];
      localStorage.setItem(STORAGE_KEY_MODE, displayMode);
      renderList(cachedPairs);
    });

    autoLoadBtn = document.createElement('span');
    autoLoadBtn.id = 'ptoc-autoload';
    autoLoadBtn.textContent = 'Auto';
    if (autoLoadEnabled) autoLoadBtn.classList.add('ptoc-on');
    autoLoadBtn.addEventListener('click', () => {
      autoLoadEnabled = !autoLoadEnabled;
      localStorage.setItem(STORAGE_KEY_AUTOLOAD, autoLoadEnabled);
      autoLoadBtn.classList.toggle('ptoc-on', autoLoadEnabled);
      if (autoLoadEnabled) {
        startAutoLoad();
      } else {
        autoLoadAborted = true;
        autoLoadBtn.classList.remove('ptoc-loading');
      }
    });

    const controls = document.createElement('span');
    controls.id = 'ptoc-header-controls';
    controls.appendChild(modeBtn);
    controls.appendChild(autoLoadBtn);

    header.appendChild(titleSpan);
    header.appendChild(controls);

    list = document.createElement('ul');
    list.id = 'ptoc-list';

    sidebar.appendChild(header);
    sidebar.appendChild(list);

    document.body.appendChild(toggleBtn);
    document.body.appendChild(sidebar);
  }

  // ── Auto-load (full-page sweep) ───────────────────────────────────────────

  function updateAutoLoadBtn(state) {
    if (!autoLoadBtn) return;
    if (state === 'idle') {
      autoLoadBtn.classList.remove('ptoc-loading', 'ptoc-done');
      autoLoadBtn.textContent = 'Auto';
    } else if (state === 'loading') {
      autoLoadBtn.classList.add('ptoc-loading');
      autoLoadBtn.classList.remove('ptoc-done');
      autoLoadBtn.innerHTML = "Auto <span class='ptoc-spinner'></span>";
    } else if (state === 'done') {
      autoLoadBtn.classList.remove('ptoc-loading');
      autoLoadBtn.classList.add('ptoc-done');
      autoLoadBtn.textContent = 'Auto \u2713';
    }
  }

  function finishAutoLoad() {
    autoLoadRunning = false;
    intersectionPaused = false;
    const container = document.querySelector('.scrollable-container');
    if (container) container.scrollTop = container.scrollHeight;
    updateAutoLoadBtn('done');
    rebindIntersectionObserver();
  }

  function cleanupAutoLoad(restoreScrollTop) {
    autoLoadRunning = false;
    autoLoadAborted = false;
    intersectionPaused = false;
    const container = document.querySelector('.scrollable-container');
    if (container && restoreScrollTop != null) container.scrollTop = restoreScrollTop;
    updateAutoLoadBtn('idle');
    rebindIntersectionObserver();
  }

  function startAutoLoad() {
    const container = document.querySelector('.scrollable-container');
    if (!container || autoLoadRunning) return;

    autoLoadRunning = true;
    autoLoadAborted = false;
    intersectionPaused = true;
    updateAutoLoadBtn('loading');

    const savedScrollTop = container.scrollTop;
    container.scrollTop = 0;
    scan();

    let lastScrollTop = -1;

    function step() {
      if (autoLoadAborted) {
        cleanupAutoLoad(savedScrollTop);
        return;
      }

      const { scrollTop, clientHeight, scrollHeight } = container;
      const atBottom = scrollTop + clientHeight >= scrollHeight - 5;
      const stalled = scrollTop === lastScrollTop;

      if (atBottom || stalled) {
        finishAutoLoad();
        return;
      }

      lastScrollTop = scrollTop;
      container.scrollTop += clientHeight * AUTOLOAD_SCROLL_FACTOR;
      scan();
      setTimeout(step, AUTOLOAD_SCROLL_DELAY);
    }

    setTimeout(step, AUTOLOAD_SCROLL_DELAY);
  }

  // ── Scan & render ─────────────────────────────────────────────────────────

  let lastKey = '';

  function isAnswerSkipped(promptEl) {
    // Walk up from the group/title element to find the turn-level container,
    // then look for a leaf <div> with exact "Answer skipped" text.
    let container = promptEl;
    for (let i = 0; i < 6; i++) {
      if (!container.parentElement) break;
      container = container.parentElement;
      if (container.classList.contains('scrollable-container')) return false;
    }
    const divs = container.getElementsByTagName('div');
    for (let i = 0; i < divs.length; i++) {
      if (divs[i].childElementCount === 0 && divs[i].textContent.trim() === 'Answer skipped') {
        return true;
      }
    }
    return false;
  }

  function scan() {
    const promptWrappers = Array.from(document.querySelectorAll('[class*="group/title"]'));
    const responseContainers = Array.from(document.querySelectorAll('[id^="markdown-content-"]'));

    // Tag each node with its type and sort by DOM order
    const allNodes = [
      ...promptWrappers.map(el => ({ el, type: 'prompt' })),
      ...responseContainers.map(el => ({ el, type: 'response' })),
    ];

    allNodes.sort((a, b) => {
      const pos = a.el.compareDocumentPosition(b.el);
      return pos & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
    });

    // Build livePairs from what's currently in the DOM
    const livePairs = [];
    for (let i = 0; i < allNodes.length; i++) {
      const node = allNodes[i];
      if (node.type !== 'prompt') continue;

      const spanEl = node.el.querySelector('span[class*="select-text"]');
      const promptText = spanEl ? (spanEl.innerText || spanEl.textContent || '').trim() : '';
      if (!promptText) continue;

      let response = null;
      let _order = null;

      if (i + 1 < allNodes.length && allNodes[i + 1].type === 'response') {
        const respNode = allNodes[i + 1];
        const orderMatch = respNode.el.id.match(/^markdown-content-(\d+)$/);
        if (orderMatch) _order = parseInt(orderMatch[1], 10);

        const p = respNode.el.querySelector('p:first-of-type');
        const fullText = p ? (p.innerText || p.textContent || '').trim() : '';
        if (fullText) {
          const truncated = fullText.length > TRUNCATE_LEN
            ? fullText.slice(0, TRUNCATE_LEN) + '\u2026'
            : fullText;
          response = { text: truncated };
        }
        i++; // always skip the response node when present
      }

      const _skipped = !response && isAnswerSkipped(node.el);
      livePairs.push({ prompt: { text: promptText }, response, _order, _promptEl: node.el, _skipped });
    }

    // Merge livePairs into cachedPairs (cache only grows, never shrinks within a navigation).
    // PRIMARY: use _order (from markdown-content-N IDs) for stable positioning across disjoint
    // virtualization windows. FALLBACK: bi-directional neighbor lookup for prompts without _order.
    for (let liveIdx = 0; liveIdx < livePairs.length; liveIdx++) {
      const live = livePairs[liveIdx];
      const existingIdx = cachedPairs.findIndex(c => c.prompt.text === live.prompt.text);

      if (existingIdx !== -1) {
        if (live.response) cachedPairs[existingIdx].response = live.response;
        cachedPairs[existingIdx]._promptEl = live._promptEl;
        if (live._order != null) cachedPairs[existingIdx]._order = live._order;
        if (live._skipped) cachedPairs[existingIdx]._skipped = true;
      } else {
        let insertAt = cachedPairs.length;

        if (live._order != null) {
          // PRIMARY: find first cached item with a higher _order and insert before it
          for (let k = 0; k < cachedPairs.length; k++) {
            if (cachedPairs[k]._order != null && cachedPairs[k]._order > live._order) {
              insertAt = k;
              break;
            }
          }
        } else {
          // FALLBACK: neighbor lookup — forward first, then backward
          for (let j = liveIdx + 1; j < livePairs.length; j++) {
            const nextIdx = cachedPairs.findIndex(c => c.prompt.text === livePairs[j].prompt.text);
            if (nextIdx !== -1) { insertAt = nextIdx; break; }
          }
          if (insertAt === cachedPairs.length) {
            for (let j = liveIdx - 1; j >= 0; j--) {
              const prevIdx = cachedPairs.findIndex(c => c.prompt.text === livePairs[j].prompt.text);
              if (prevIdx !== -1) { insertAt = prevIdx + 1; break; }
            }
          }
        }

        cachedPairs.splice(insertAt, 0, live);
      }
    }

    // Rebuild activeElements from cache (null for virtualized-away items)
    activeElements = cachedPairs.map(p => p._promptEl || null);
    rebindIntersectionObserver();  // always rebind — DOM refs may have changed

    // Change detection on stable cachedPairs (never shrinks, so key never shrinks)
    const newKey = cachedPairs.map(p => p.prompt.text + (p._skipped ? '\x01skip' : '') + (p.response?.text ?? '')).join('|');
    if (newKey === lastKey) return;

    lastKey = newKey;
    renderList(cachedPairs);
    updateSidebarWidth();
  }

  function setActive(idx) {
    list.querySelectorAll('.ptoc-entry').forEach((el) => el.classList.remove('ptoc-active'));
    const activeEl = list.querySelector(`.ptoc-entry[data-index="${idx}"]`);
    if (activeEl) activeEl.classList.add('ptoc-active');
  }

  function doScroll(el, idx) {
    intersectionPaused = true;
    setActive(idx);
    el.scrollIntoView({ behavior: 'instant', block: 'start' });
    setTimeout(() => { intersectionPaused = false; }, 400);
  }

  const PROG_MAX = 10;
  const PROG_DELAY = 300;

  function progressiveScroll(targetIdx, attempt) {
    if (attempt >= PROG_MAX) return;

    // Find nearest connected element to scroll toward target
    let nearest = -1;
    let nearestDist = Infinity;
    for (let i = 0; i < activeElements.length; i++) {
      const el = activeElements[i];
      if (el && el.isConnected) {
        const dist = Math.abs(i - targetIdx);
        if (dist < nearestDist) { nearestDist = dist; nearest = i; }
      }
    }

    if (nearest === -1) return;

    intersectionPaused = true;
    setActive(targetIdx);
    activeElements[nearest].scrollIntoView({ behavior: 'instant', block: 'start' });

    setTimeout(() => {
      scan(); // force DOM refresh so virtualizer-loaded elements are picked up
      const target = activeElements[targetIdx];
      if (target && target.isConnected) {
        doScroll(target, targetIdx);
      } else {
        progressiveScroll(targetIdx, attempt + 1);
      }
    }, PROG_DELAY);
  }

  function scrollToEntry(idx) {
    const target = activeElements[idx];
    if (target && target.isConnected) {
      doScroll(target, idx);
    } else {
      progressiveScroll(idx, 0);
    }
  }

  function setUIVisible(visible) {
    toggleBtn.style.visibility = visible ? '' : 'hidden';
    if (visible && !userHidden) {
      sidebar.classList.remove('ptoc-hidden');
      updateSidebarWidth();
    } else if (!visible) {
      sidebar.classList.add('ptoc-hidden');
    }
  }

  function updateSidebarWidth() {
    const threadBody = document.querySelector('[class*="max-w-threadContentWidth"]');
    if (!threadBody) return;
    const threadRight = threadBody.getBoundingClientRect().right;
    const available = window.innerWidth - threadRight - 20 - SIDEBAR_GAP;
    sidebar.style.width = Math.max(SIDEBAR_MIN_WIDTH, available) + 'px';
  }

  function renderList(pairs) {
    list.innerHTML = '';

    if (pairs.length < 1) {
      setUIVisible(false);
      return;
    }

    setUIVisible(true);

    pairs.forEach(({ prompt, response, _skipped }, i) => {
      const li = document.createElement('li');
      li.className = 'ptoc-entry';
      li.dataset.index = i;

      const num = document.createElement('span');
      num.className = 'ptoc-num';
      num.textContent = i + 1;

      const body = document.createElement('div');
      body.className = 'ptoc-body';

      if (displayMode === 'both' || displayMode === 'prompts') {
        const promptEl = document.createElement('div');
        promptEl.className = 'ptoc-prompt';
        promptEl.textContent = prompt.text;
        body.appendChild(promptEl);
      }

      if (displayMode === 'both' || displayMode === 'responses') {
        if (response) {
          const responseEl = document.createElement('div');
          responseEl.className = 'ptoc-response';
          responseEl.textContent = response.text;
          body.appendChild(responseEl);
        } else if (_skipped) {
          const skippedEl = document.createElement('div');
          skippedEl.className = 'ptoc-response ptoc-skipped';
          skippedEl.textContent = 'Skipped';
          body.appendChild(skippedEl);
        } else if (displayMode === 'responses') {
          // Skeleton only in responses-only mode; 'both' with no response shows nothing
          const skeleton = document.createElement('div');
          skeleton.className = 'ptoc-skeleton';
          body.appendChild(skeleton);
        }
      }

      li.appendChild(num);
      li.appendChild(body);

      li.addEventListener('click', () => scrollToEntry(i));

      list.appendChild(li);
    });
  }

  // ── Active entry via IntersectionObserver ─────────────────────────────────

  function rebindIntersectionObserver() {
    if (intersectionObs) intersectionObs.disconnect();
    const nonNullElements = activeElements.filter(el => el !== null);
    if (nonNullElements.length === 0) return;

    intersectionObs = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (!intersectionPaused && entry.isIntersecting) {
            const idx = activeElements.indexOf(entry.target);
            if (idx !== -1) setActive(idx);
          }
        });
      },
      { threshold: 0.3 }
    );

    nonNullElements.forEach((el) => intersectionObs.observe(el));
  }

  // ── MutationObserver on thread container ──────────────────────────────────

  function startMutationObserver(container) {
    if (mutationObs) mutationObs.disconnect();

    mutationObs = new MutationObserver(() => {
      clearTimeout(debounceTimer);
      debounceTimer = setTimeout(scan, DEBOUNCE_MS);
    });

    mutationObs.observe(container, { childList: true, subtree: true });
  }

  // ── Wait for container with retry ─────────────────────────────────────────

  function waitForContainer(attempts) {
    const container = document.querySelector('.scrollable-container') || document.body;
    if (container !== document.body || attempts <= 0) {
      startMutationObserver(container);
      scan();
      if (autoLoadEnabled) setTimeout(startAutoLoad, 300);
      return;
    }
    setTimeout(() => waitForContainer(attempts - 1), RETRY_INTERVAL_MS);
  }

  // ── SPA navigation detection ──────────────────────────────────────────────

  function onNavigation() {
    autoLoadAborted = true;
    userHidden = false;
    lastKey = '';
    cachedPairs = [];
    setUIVisible(false); // hide immediately; scan() re-shows only if it finds pairs
    waitForContainer(RETRY_MAX); // retries until .scrollable-container is in the DOM
  }

  function watchNavigation() {
    // Watch title changes as a proxy for SPA navigation
    const titleObs = new MutationObserver(() => {
      if (location.href !== currentHref) {
        currentHref = location.href;
        setTimeout(onNavigation, 500); // brief delay for new DOM to settle
      }
    });

    const titleEl = document.querySelector('title');
    if (titleEl) {
      titleObs.observe(titleEl, { childList: true });
    }

    // Also intercept pushState / replaceState
    const origPush = history.pushState.bind(history);
    const origReplace = history.replaceState.bind(history);

    history.pushState = function (...args) {
      origPush(...args);
      if (location.href !== currentHref) {
        currentHref = location.href;
        setTimeout(onNavigation, 500);
      }
    };

    history.replaceState = function (...args) {
      origReplace(...args);
      if (location.href !== currentHref) {
        currentHref = location.href;
        setTimeout(onNavigation, 500);
      }
    };

    window.addEventListener('popstate', () => {
      if (location.href !== currentHref) {
        currentHref = location.href;
        setTimeout(onNavigation, 500);
      }
    });
  }

  // ── Bootstrap ─────────────────────────────────────────────────────────────

  function init() {
    injectStyles();
    buildUI();
    waitForContainer(RETRY_MAX);
    watchNavigation();
    window.addEventListener('resize', updateSidebarWidth);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();