在chatgpt中实现gemini中的侧边栏

首先谢谢这个帖子的佬友推荐了他的脚本,虽然挺好用但是我觉得没有那么还原哈基米里的侧边栏。
https://linux.do/t/topic/1341845?u=hkxbk
于是我这个纯小白完全靠哈基米自己做了个脚本,并且还加了个可以给节点上色的功能。效果如下:


然后本来是加了一个提问数量过多时间轴可以滑动的功能,但是因为我没有在一段对话内提出过这么长的问题,所以就没有测试了 :distorted_face:
哈基米写的代码如下,复制到油猴里即可使用:

// ==UserScript==
// @name         ChatGPT 侧边时间轴 (V11.0 颜色标记收藏版)
// @namespace    http://tampermonkey.net/
// @version      11.0
// @description  右键点击节点可选择颜色进行标记(低饱和度),支持持久化保存,完美支持长列表滚动与置底吸附。
// @author       YourName
// @match        https://chatgpt.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=openai.com
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // 全局状态
    let isHoveringSidebar = false;
    let currentColorMap = {}; // 存储当前对话的颜色配置 { index: colorHex }
    
    // 配置参数
    const CONFIG = {
        nodeHeight: 16,
        triggerHeight: 22,
        sidebarRight: 32,
        tooltipGap: 18,
        // 低饱和度配色表 (Tailwind 400/500 mix)
        colors: [
            { name: 'Default', hex: '#9CA3AF' }, // 灰色 (恢复默认)
            { name: 'Red',     hex: '#F87171' }, // 柔红
            { name: 'Orange',  hex: '#FB923C' }, // 暖橙
            { name: 'Yellow',  hex: '#FACC15' }, // 姜黄
            { name: 'Green',   hex: '#4ADE80' }, // 草绿
            { name: 'Cyan',    hex: '#22D3EE' }, // 青色
            { name: 'Blue',    hex: '#60A5FA' }, // 天蓝
            { name: 'Purple',  hex: '#C084FC' }  // 香芋紫
        ]
    };

    // --- 1. CSS 样式 ---
    const styleSheet = document.createElement('style');
    styleSheet.innerText = `
        /* 主容器 */
        #gpt-timeline-wrapper {
            position: fixed;
            top: 80px;
            bottom: 120px;
            right: ${CONFIG.sidebarRight}px;
            width: 36px;
            z-index: 9990;
            pointer-events: auto !important; 
            display: flex;
            flex-direction: column;
            align-items: center;
            overflow-y: hidden;
            transition: right 0.3s ease;
            contain: layout; 
        }
        
        #gpt-timeline-wrapper:hover {
            overflow-y: auto;
        }

        #gpt-timeline-wrapper::-webkit-scrollbar { width: 0 !important; }
        #gpt-timeline-wrapper { scrollbar-width: none; }

        /* 轨道 */
        #gpt-timeline-track {
            position: relative;
            width: 100%;
            min-height: 100%;
            padding-bottom: 30px;
        }

        /* 轴线 */
        #gpt-timeline-line {
            position: absolute;
            left: 50%;
            top: 0; bottom: 0;
            width: 2px;
            background-color: #E5E7EB;
            transform: translateX(-50%);
            border-radius: 99px;
            z-index: 0;
        }
        @media (prefers-color-scheme: dark) {
            #gpt-timeline-line { background-color: #4B5563; }
        }

        /* 节点 */
        .gpt-timeline-node {
            position: absolute;
            left: 50%;
            transform: translateX(-50%);
            width: 6px;
            height: ${CONFIG.nodeHeight}px;
            border-radius: 4px;
            /* 默认背景色,会被行内样式覆盖 */
            background-color: #9CA3AF; 
            cursor: pointer;
            z-index: 10;
            transition: background-color 0.2s, height 0.2s, width 0.2s, transform 0.2s;
        }

        .gpt-timeline-node::before {
            content: "";
            position: absolute;
            top: -6px; bottom: -6px; left: -20px; right: -20px;
            background: transparent;
        }

        /* 悬停效果 (注意:如果设置了自定义颜色,悬停时不应强制变蓝,而是变亮或保持)
           这里使用 filter brightness 来处理所有颜色的悬停态 */
        .gpt-timeline-node:hover {
            height: 24px;
            width: 8px;
            filter: brightness(1.1) saturate(1.2); /* 稍微提亮 */
            box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.1);
            z-index: 100;
        }

        /* 吸附节点 */
        #gpt-timeline-sticky-node {
            position: absolute;
            bottom: 5px;
            left: 50%;
            transform: translateX(-50%);
            z-index: 100;
            width: 6px;
            height: ${CONFIG.nodeHeight}px;
            border-radius: 4px;
            background-color: #9CA3AF;
            cursor: pointer;
            opacity: 0; 
            pointer-events: none;
            transition: opacity 0.2s ease;
        }
        #gpt-timeline-sticky-node:hover {
            height: 24px;
            width: 8px;
            filter: brightness(1.1) saturate(1.2);
            box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.1);
        }

        /* 全局气泡 */
        #gpt-timeline-global-tooltip {
            position: fixed;
            z-index: 2147483647;
            background-color: #FFFFFF;
            color: #1F2937;
            border: 1px solid #E5E7EB;
            box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
            padding: 8px 12px;
            border-radius: 8px;
            font-size: 13px;
            line-height: 1.5;
            font-family: sans-serif;
            font-weight: 500;
            max-width: 280px;
            pointer-events: none; 
            visibility: hidden;
            opacity: 0;
            top: 0; left: 0;
            transition: opacity 0.15s ease-out;
        }
        #gpt-timeline-global-tooltip::after {
            content: "";
            position: absolute;
            top: 50%;
            right: -7px; 
            margin-top: -7px;
            border-width: 7px;
            border-style: solid;
            border-color: transparent transparent transparent #FFFFFF;
            filter: drop-shadow(1px 0 0 #E5E7EB);
        }

        /* --- 右键颜色菜单 --- */
        #gpt-timeline-context-menu {
            position: fixed;
            z-index: 2147483647;
            background: rgba(255, 255, 255, 0.95);
            backdrop-filter: blur(5px);
            border: 1px solid #E5E7EB;
            box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
            border-radius: 12px;
            padding: 8px;
            display: grid;
            grid-template-columns: repeat(4, 1fr);
            gap: 8px;
            opacity: 0;
            visibility: hidden;
            transform: scale(0.9);
            transition: opacity 0.15s, transform 0.15s;
        }
        /* 暗色模式适配菜单 */
        @media (prefers-color-scheme: dark) {
            #gpt-timeline-context-menu {
                background: rgba(32, 33, 35, 0.95);
                border-color: #4B5563;
            }
        }

        .gpt-color-option {
            width: 20px;
            height: 20px;
            border-radius: 50%;
            cursor: pointer;
            border: 2px solid transparent;
            transition: transform 0.1s;
        }
        .gpt-color-option:hover {
            transform: scale(1.2);
            border-color: rgba(0,0,0,0.2);
        }
    `;
    document.head.appendChild(styleSheet);

    // --- 2. 存储管理 (LocalStorage) ---
    // 根据 URL 生成唯一的 Key,这样每个对话的标记是独立的
    function getStorageKey() {
        const path = window.location.pathname;
        // 简单处理:提取 URL 中的 UUID,如果没有则认为是临时对话
        const match = path.match(/\/c\/([a-z0-9-]+)/) || path.match(/\/chat\/([a-z0-9-]+)/);
        const id = match ? match[1] : 'temp-chat'; 
        return `gpt-timeline-colors-${id}`;
    }

    function loadColors() {
        const key = getStorageKey();
        try {
            const data = localStorage.getItem(key);
            currentColorMap = data ? JSON.parse(data) : {};
        } catch (e) {
            currentColorMap = {};
        }
    }

    function saveColor(index, colorHex) {
        const key = getStorageKey();
        if (colorHex === CONFIG.colors[0].hex) {
            // 如果选了默认灰色,则从存储中删除
            delete currentColorMap[index];
        } else {
            currentColorMap[index] = colorHex;
        }
        localStorage.setItem(key, JSON.stringify(currentColorMap));
        
        // 立即刷新视图
        updateTimelineNodesStyle();
    }

    // --- 3. UI 组件 ---
    
    // 气泡
    function showTooltip(text, targetNode) {
        let tooltip = document.getElementById('gpt-timeline-global-tooltip');
        if (!tooltip) {
            tooltip = document.createElement('div');
            tooltip.id = 'gpt-timeline-global-tooltip';
            document.body.appendChild(tooltip);
        }
        tooltip.innerText = text;
        tooltip.style.display = 'block';
        tooltip.style.visibility = 'hidden';
        tooltip.style.opacity = '0';

        const tooltipWidth = tooltip.offsetWidth;
        const tooltipHeight = tooltip.offsetHeight;
        const nodeRect = targetNode.getBoundingClientRect();

        const top = nodeRect.top + (nodeRect.height / 2) - (tooltipHeight / 2);
        const left = nodeRect.left - tooltipWidth - CONFIG.tooltipGap;

        tooltip.style.top = `${top}px`;
        tooltip.style.left = `${left}px`;
        
        requestAnimationFrame(() => {
            tooltip.style.visibility = 'visible';
            tooltip.style.opacity = '1';
        });
    }

    function hideTooltip() {
        const tooltip = document.getElementById('gpt-timeline-global-tooltip');
        if (tooltip) {
            tooltip.style.opacity = '0';
            setTimeout(() => { 
                if (tooltip.style.opacity === '0') tooltip.style.visibility = 'hidden'; 
            }, 150);
        }
    }

    // 右键菜单
    let activeContextMenuIndex = -1; // 当前正在操作哪个节点的索引

    function createContextMenu() {
        if (document.getElementById('gpt-timeline-context-menu')) return;

        const menu = document.createElement('div');
        menu.id = 'gpt-timeline-context-menu';
        
        CONFIG.colors.forEach(c => {
            const dot = document.createElement('div');
            dot.className = 'gpt-color-option';
            dot.style.backgroundColor = c.hex;
            dot.title = c.name;
            dot.onclick = (e) => {
                e.stopPropagation();
                if (activeContextMenuIndex !== -1) {
                    saveColor(activeContextMenuIndex, c.hex);
                }
                hideContextMenu();
            };
            menu.appendChild(dot);
        });

        document.body.appendChild(menu);

        // 点击页面其他地方关闭菜单
        document.addEventListener('click', hideContextMenu);
        // 滚动时关闭菜单
        document.addEventListener('scroll', hideContextMenu, true);
    }

    function showContextMenu(e, index) {
        e.preventDefault(); // 阻止默认右键菜单
        activeContextMenuIndex = index;

        createContextMenu();
        const menu = document.getElementById('gpt-timeline-context-menu');
        
        // 定位到鼠标左侧
        menu.style.visibility = 'hidden';
        menu.style.display = 'grid';
        
        const menuWidth = 120; // 估算
        const left = e.clientX - menuWidth - 10;
        const top = e.clientY - 40;

        menu.style.left = `${left}px`;
        menu.style.top = `${top}px`;
        
        requestAnimationFrame(() => {
            menu.style.visibility = 'visible';
            menu.style.opacity = '1';
            menu.style.transform = 'scale(1)';
        });
    }

    function hideContextMenu() {
        const menu = document.getElementById('gpt-timeline-context-menu');
        if (menu) {
            menu.style.opacity = '0';
            menu.style.transform = 'scale(0.9)';
            setTimeout(() => { menu.style.visibility = 'hidden'; }, 150);
        }
    }

    // --- 4. 核心逻辑 ---

    // 辅助:只更新节点的颜色,不重新生成 DOM
    function updateTimelineNodesStyle() {
        const track = document.getElementById('gpt-timeline-track');
        if (!track) return;
        
        // 更新普通节点
        const nodes = track.querySelectorAll('.gpt-timeline-node');
        nodes.forEach((node, i) => {
            const savedColor = currentColorMap[i];
            node.style.backgroundColor = savedColor || CONFIG.colors[0].hex;
        });

        // 更新吸附节点 (它对应最后一个索引)
        const stickyNode = document.getElementById('gpt-timeline-sticky-node');
        if (stickyNode) {
            const lastIndex = nodes.length - 1;
            const savedColor = currentColorMap[lastIndex];
            stickyNode.style.backgroundColor = savedColor || CONFIG.colors[0].hex;
        }
    }

    function findScrollContainer() {
        const candidates = document.querySelectorAll('div[class*="overflow-y-auto"], main div.h-full.overflow-y-auto');
        for (let el of candidates) {
            if (el.scrollHeight > el.clientHeight && el.scrollHeight > 0) return el;
        }
        return document.documentElement;
    }

    function updateTimeline() {
        if (isHoveringSidebar) return;

        // 加载颜色数据
        loadColors();

        let wrapper = document.getElementById('gpt-timeline-wrapper');
        let track, line, stickyNode;

        if (!wrapper) {
            wrapper = document.createElement('div');
            wrapper.id = 'gpt-timeline-wrapper';
            wrapper.addEventListener('mouseenter', () => isHoveringSidebar = true);
            wrapper.addEventListener('mouseleave', () => isHoveringSidebar = false);

            track = document.createElement('div');
            track.id = 'gpt-timeline-track';
            
            line = document.createElement('div');
            line.id = 'gpt-timeline-line';
            
            stickyNode = document.createElement('div');
            stickyNode.id = 'gpt-timeline-sticky-node';
            
            track.appendChild(line);
            wrapper.appendChild(track);
            wrapper.appendChild(stickyNode);
            document.body.appendChild(wrapper);

            // 滚动监听
            wrapper.onscroll = () => {
                const scrollDiff = track.scrollHeight - wrapper.scrollTop - wrapper.clientHeight;
                if (scrollDiff > 30) {
                    stickyNode.style.opacity = '1';
                    stickyNode.style.pointerEvents = 'auto';
                } else {
                    stickyNode.style.opacity = '0';
                    stickyNode.style.pointerEvents = 'none';
                    hideTooltip();
                }
            };
        } else {
            track = document.getElementById('gpt-timeline-track');
            stickyNode = document.getElementById('gpt-timeline-sticky-node');
        }

        const scrollContainer = findScrollContainer();
        if (!scrollContainer) return;

        const userMessages = document.querySelectorAll('[data-message-author-role="user"]');
        if (userMessages.length === 0) return;

        const wrapperHeight = wrapper.clientHeight || (window.innerHeight - 200);
        const nodeCount = userMessages.length;
        const isOverflowMode = (wrapperHeight / nodeCount) < CONFIG.triggerHeight;

        const existingNodes = track.querySelectorAll('.gpt-timeline-node');
        existingNodes.forEach(n => n.remove());

        if (isOverflowMode) {
            track.style.height = `${nodeCount * CONFIG.triggerHeight + 40}px`;
            stickyNode.style.display = 'block';
        } else {
            track.style.height = '100%';
            stickyNode.style.display = 'none';
        }

        const totalContentHeight = scrollContainer.scrollHeight;
        const containerRect = scrollContainer.getBoundingClientRect();
        const scrollTop = scrollContainer.scrollTop;

        // 渲染节点
        userMessages.forEach((msg, index) => {
            let text = msg.innerText || "";
            text = text.replace(/\s+/g, ' ').trim();
            if (!text) return;
            if (text.length > 200) text = text.substring(0, 200) + "...";

            const node = document.createElement('div');
            node.className = 'gpt-timeline-node';

            // 设置初始颜色
            if (currentColorMap[index]) {
                node.style.backgroundColor = currentColorMap[index];
            }

            if (isOverflowMode) {
                node.style.top = `${15 + index * CONFIG.triggerHeight}px`;
            } else {
                const msgRect = msg.getBoundingClientRect();
                const absoluteTop = msgRect.top - containerRect.top + scrollTop;
                let percent = (absoluteTop / totalContentHeight) * 100;
                if (percent < 0) percent = 0;
                if (percent > 99) percent = 99;
                node.style.top = `${percent}%`;
            }

            // 事件:气泡
            node.addEventListener('mouseenter', () => showTooltip(text, node));
            node.addEventListener('mouseleave', () => hideTooltip());
            
            // 事件:右键菜单
            node.addEventListener('contextmenu', (e) => showContextMenu(e, index));

            // 事件:点击跳转
            node.onclick = (e) => {
                e.stopPropagation();
                msg.scrollIntoView({ behavior: 'smooth', block: 'center' });
                highlightMessage(msg);
            };

            track.appendChild(node);
        });

        // 配置吸附节点 (总是对应最后一个)
        if (userMessages.length > 0) {
            const lastIndex = userMessages.length - 1;
            const lastMsg = userMessages[lastIndex];
            let lastText = lastMsg.innerText || "";
            lastText = lastText.replace(/\s+/g, ' ').trim().substring(0, 200);

            // 更新引用,防止事件重复
            const newSticky = stickyNode.cloneNode(true);
            stickyNode.parentNode.replaceChild(newSticky, stickyNode);
            stickyNode = newSticky;

            // 设置颜色
            if (currentColorMap[lastIndex]) {
                stickyNode.style.backgroundColor = currentColorMap[lastIndex];
            } else {
                stickyNode.style.backgroundColor = CONFIG.colors[0].hex;
            }

            stickyNode.addEventListener('mouseenter', () => showTooltip(lastText, stickyNode));
            stickyNode.addEventListener('mouseleave', () => hideTooltip());
            
            // 吸附节点也支持右键标记颜色!
            stickyNode.addEventListener('contextmenu', (e) => showContextMenu(e, lastIndex));
            
            stickyNode.onclick = (e) => {
                e.stopPropagation();
                lastMsg.scrollIntoView({ behavior: 'smooth', block: 'center' });
                highlightMessage(lastMsg);
                wrapper.scrollTo({ top: track.scrollHeight, behavior: 'smooth' });
            };
        }
    }

    function highlightMessage(msg) {
        msg.style.transition = 'background-color 0.3s';
        const originalBg = msg.style.backgroundColor;
        msg.style.backgroundColor = 'rgba(59, 130, 246, 0.15)';
        setTimeout(() => { msg.style.backgroundColor = originalBg; }, 800);
    }

    // --- 5. 初始化 ---
    let debounceTimer;
    const observer = new MutationObserver(() => {
        clearTimeout(debounceTimer);
        if (!isHoveringSidebar) {
            debounceTimer = setTimeout(updateTimeline, 600);
        }
    });

    function init() {
        setTimeout(updateTimeline, 1500);
        observer.observe(document.body, { childList: true, subtree: true });

        let lastUrl = location.href;
        new MutationObserver(() => {
            if (location.href !== lastUrl) {
                lastUrl = location.href;
                isHoveringSidebar = false;
                hideTooltip(); 
                hideContextMenu();
                setTimeout(() => {
                    const wrapper = document.getElementById('gpt-timeline-wrapper');
                    if (wrapper) wrapper.remove();
                    updateTimeline();
                }, 1000);
            }
        }).observe(document, {subtree: true, childList: true});

        window.addEventListener('resize', () => {
             if (!isHoveringSidebar) {
                clearTimeout(debounceTimer);
                debounceTimer = setTimeout(updateTimeline, 500);
             }
        });
    }

    init();

})();
5 个赞

感谢大佬 !

1 个赞

感谢分享

1 个赞

又用gpt改了一下,点击按钮可以显示所有的提问信息了。还是gpt好用,免费的gemini用多了就变傻掉了

// ==UserScript==
// @name         ChatGPT Question Axis Sidebar (panel list)
// @namespace    https://tampermonkey.net/
// @version      0.7.0
// @description  1px axis line + nodes + palette + pinned nodes; total visible nodes=18 incl pins; thumb anchors near current node; small tooltip w/o scrollbar; toggle panel shows ALL questions list.
// @match        https://chat.openai.com/*
// @match        https://chatgpt.com/*
// @run-at       document-idle
// @grant        GM_addStyle
// ==/UserScript==

(function () {
  "use strict";

  /**********************
   * Config
   **********************/
  const NODE_MODE = "all_user_turns"; // "all_user_turns" or "questions_only"
  const TOTAL_VISIBLE_NODES = 18;     // includes pinned nodes
  const SCROLL_CENTER_BIAS = 0.33;
  const PANEL_WIDTH = 320;

  // Lighter but more vivid (pastel-vivid) colors
  const COLOR_LIST = [
    { key: "gray",   label: "灰",   hex: null },
    { key: "red",    label: "红",   hex: "#E66B6B" },
    { key: "orange", label: "橙",   hex: "#E59A5A" },
    { key: "yellow", label: "黄",   hex: "#E5CD55" },
    { key: "green",  label: "绿",   hex: "#6CCB8D" },
    { key: "cyan",   label: "青",   hex: "#4FCAD0" },
    { key: "blue",   label: "蓝",   hex: "#5F98F0" },
    { key: "purple", label: "紫",   hex: "#B07BEF" },
  ];

  /**********************
   * State
   **********************/
  const state = {
    root: null,
    axis: null,
    nodesLayer: null,
    tooltip: null,
    thumb: null,
    track: null,
    palette: null,

    pinTop: null,
    pinBottom: null,

    panel: null,
    panelList: null,
    panelCount: null,
    panelToggleBtn: null,
    panelOpen: false,

    scrollContainer: null,
    questions: [],

    colorMap: {},
    storageKey: "",

    dragging: false,
    rafScroll: 0,
    rescanTimer: 0,
    lastUrl: "",
    _onScroll: null,

    currentIdxAll: 0,
  };

  /**********************
   * Utilities
   **********************/
  function safeGMAddStyle(css) {
    if (typeof GM_addStyle === "function") GM_addStyle(css);
    else {
      const s = document.createElement("style");
      s.textContent = css;
      document.head.appendChild(s);
    }
  }
  function debounceRescan(delay = 450) {
    clearTimeout(state.rescanTimer);
    state.rescanTimer = setTimeout(() => rescan(), delay);
  }
  function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
  function hash32(str) {
    let h = 0x811c9dc5;
    for (let i = 0; i < str.length; i++) {
      h ^= str.charCodeAt(i);
      h = Math.imul(h, 0x01000193) >>> 0;
    }
    return h >>> 0;
  }
  function getConversationId() {
    const m = location.pathname.match(/\/c\/([a-zA-Z0-9_-]+)/);
    return m ? m[1] : "global";
  }
  function getScrollTop(sc) {
    if (sc === window) return window.scrollY || document.documentElement.scrollTop || 0;
    return sc.scrollTop;
  }
  function setScrollTop(sc, top) {
    if (sc === window) window.scrollTo({ top, behavior: "auto" });
    else sc.scrollTop = top;
  }
  function getScrollMetrics(sc) {
    if (sc === window) {
      const doc = document.documentElement;
      const scrollTop = getScrollTop(window);
      const clientHeight = window.innerHeight;
      const scrollHeight = doc.scrollHeight;
      const maxScrollTop = Math.max(1, scrollHeight - clientHeight);
      return { scrollTop, clientHeight, scrollHeight, maxScrollTop };
    } else {
      const scrollTop = sc.scrollTop;
      const clientHeight = sc.clientHeight;
      const scrollHeight = sc.scrollHeight;
      const maxScrollTop = Math.max(1, scrollHeight - clientHeight);
      return { scrollTop, clientHeight, scrollHeight, maxScrollTop };
    }
  }
  function getOffsetToScrollContainer(el, sc) {
    const r = el.getBoundingClientRect();
    if (sc === window) return r.top + getScrollTop(window);
    const cr = sc.getBoundingClientRect();
    return (r.top - cr.top) + sc.scrollTop;
  }
  function isLikelyQuestion(text) {
    if (/[??]/.test(text)) return true;
    if (/(怎么|如何|为啥|为什么|是否|能否|可以吗|行吗|吗$|呢$|如何做|怎么做)/.test(text.trim())) return true;
    return false;
  }
  function getUserMessageBlocks() {
    return Array.from(document.querySelectorAll('[data-message-author-role="user"]'));
  }
  function findScrollContainer() {
    const anyTurn =
      document.querySelector('[data-testid="conversation-turn"]') ||
      document.querySelector('[data-message-author-role]');
    if (!anyTurn) return window;

    let el = anyTurn.parentElement;
    while (el && el !== document.body) {
      const st = getComputedStyle(el);
      const oy = st.overflowY;
      if ((oy === "auto" || oy === "scroll") && el.scrollHeight > el.clientHeight + 80) return el;
      el = el.parentElement;
    }

    const main = document.querySelector("main");
    if (main) {
      const st = getComputedStyle(main);
      const oy = st.overflowY;
      if ((oy === "auto" || oy === "scroll") && main.scrollHeight > main.clientHeight + 80) return main;
    }
    return window;
  }
  function pxToNum(px) {
    const n = parseFloat(px || "");
    return Number.isFinite(n) ? n : NaN;
  }

  /**********************
   * Colors
   **********************/
  function loadColors() {
    const cid = getConversationId();
    state.storageKey = `tm_qaxis_colors::${cid}`;
    try { state.colorMap = JSON.parse(localStorage.getItem(state.storageKey) || "{}") || {}; }
    catch { state.colorMap = {}; }
  }
  function saveColors() {
    try { localStorage.setItem(state.storageKey, JSON.stringify(state.colorMap || {})); } catch {}
  }
  function applyNodeColor(nodePill, colorKey) {
    const c = COLOR_LIST.find((x) => x.key === colorKey);
    if (!c || !c.hex) { nodePill.style.background = ""; return; }
    nodePill.style.background = c.hex;
  }
  function getColorKeyFor(key) { return state.colorMap[key] || "gray"; }
  function getColorHexFor(key) {
    const ck = getColorKeyFor(key);
    const c = COLOR_LIST.find((x) => x.key === ck);
    return c?.hex || null;
  }
  function isMarked(key) { return getColorKeyFor(key) !== "gray"; }

  /**********************
   * CSS
   **********************/
  function injectCSS() {
    safeGMAddStyle(`
#tm-qaxis-root {
  --tm-qaxis-panel-w: 0px;

  position: fixed;
  top: 0;
  right: 0;
  width: calc(52px + var(--tm-qaxis-panel-w));
  height: 100vh;
  z-index: 2147483647;
  pointer-events: none;
  font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
}
#tm-qaxis-root.panel-open { --tm-qaxis-panel-w: ${PANEL_WIDTH}px; }

#tm-qaxis-root .tm-qaxis-axis {
  position: absolute;
  top: 84px;
  bottom: 64px;
  right: calc(var(--tm-qaxis-panel-w) + 6px);
  width: 34px;
  pointer-events: auto;
}

#tm-qaxis-root .tm-qaxis-track {
  position: absolute;
  left: 50%;
  top: 0;
  bottom: 0;
  width: 1px;
  transform: translateX(-50%);
  background: var(--tm-qaxis-line);
}

#tm-qaxis-root .tm-qaxis-nodes { position: absolute; inset: 0; }

#tm-qaxis-root .tm-qaxis-thumb {
  position: absolute;
  left: 50%;
  width: 10px;
  height: 58px;
  transform: translate(-50%, -50%);
  border-radius: 999px;
  background: var(--tm-qaxis-thumb);
  box-shadow: 0 6px 18px var(--tm-qaxis-shadow);
  cursor: grab;
  transition: background 120ms ease, box-shadow 120ms ease;
}
#tm-qaxis-root .tm-qaxis-thumb.dragging {
  cursor: grabbing;
  background: var(--tm-qaxis-thumb-drag);
  box-shadow: 0 10px 24px var(--tm-qaxis-shadow);
}
#tm-qaxis-root .tm-qaxis-thumb::before {
  content: "";
  position: absolute;
  inset: -10px -10px;
  border-radius: 999px;
  background: transparent;
}

#tm-qaxis-root .tm-qaxis-node-wrap {
  position: absolute;
  left: 50%;
  width: 34px; height: 34px;
  transform: translate(-50%, -50%);
  display: grid;
  place-items: center;
  cursor: pointer;
  transition: opacity 160ms ease, transform 160ms ease;
}
#tm-qaxis-root .tm-qaxis-node-wrap.hidden {
  opacity: 0 !important;
  transform: translate(-50%, -50%) translateX(8px) scale(0.96);
  pointer-events: none;
}

#tm-qaxis-root .tm-qaxis-node {
  width: 7px; height: 18px;
  border-radius: 999px;
  background: var(--tm-qaxis-node);
  transition: background 140ms ease, transform 140ms ease, box-shadow 140ms ease;
}
#tm-qaxis-root .tm-qaxis-node-wrap:hover .tm-qaxis-node {
  transform: scale(1.06);
  background: var(--tm-qaxis-node-hover);
}
#tm-qaxis-root .tm-qaxis-node-wrap.pin .tm-qaxis-node {
  box-shadow: 0 0 0 1px var(--tm-qaxis-pin-ring);
}

/* Toggle button */
#tm-qaxis-root .tm-qaxis-toggle {
  position: absolute;
  right: calc(var(--tm-qaxis-panel-w) + 7px);
  top: 44px;
  width: 26px;
  height: 26px;
  border-radius: 10px;
  background: var(--tm-qaxis-btn-bg);
  border: 1px solid var(--tm-qaxis-tooltip-border);
  box-shadow: 0 8px 22px var(--tm-qaxis-shadow);
  display: grid;
  place-items: center;
  pointer-events: auto;
  cursor: pointer;
  user-select: none;
  transition: transform 120ms ease, background 120ms ease;
}
#tm-qaxis-root .tm-qaxis-toggle:hover { transform: translateY(-1px); }
#tm-qaxis-root .tm-qaxis-toggle:active { transform: translateY(0); }
#tm-qaxis-root .tm-qaxis-toggle svg { opacity: 0.85; }

/* Panel */
#tm-qaxis-root .tm-qaxis-panel {
  position: absolute;
  top: 12px;
  bottom: 12px;
  right: 8px;
  width: var(--tm-qaxis-panel-w);
  border-radius: 14px;
  background: var(--tm-qaxis-tooltip-bg);
  border: 1px solid var(--tm-qaxis-tooltip-border);
  box-shadow: 0 14px 34px var(--tm-qaxis-shadow);
  pointer-events: auto;

  overflow: hidden;
  opacity: 0;
  transform: translateX(14px);
  transition: opacity 160ms ease, transform 160ms ease;
}
#tm-qaxis-root.panel-open .tm-qaxis-panel {
  opacity: 1;
  transform: translateX(0);
}

#tm-qaxis-root .tm-qaxis-panel-header {
  height: 42px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 10px 0 12px;
  border-bottom: 1px solid var(--tm-qaxis-tooltip-border);
}
#tm-qaxis-root .tm-qaxis-panel-title {
  font-size: 12.5px;
  color: var(--tm-qaxis-tooltip-fg);
  opacity: 0.92;
}
#tm-qaxis-root .tm-qaxis-panel-close {
  width: 26px;
  height: 26px;
  border-radius: 10px;
  display: grid;
  place-items: center;
  cursor: pointer;
  border: 1px solid var(--tm-qaxis-tooltip-border);
  background: var(--tm-qaxis-btn-bg);
}
#tm-qaxis-root .tm-qaxis-panel-close:hover { filter: brightness(0.98); }

#tm-qaxis-root .tm-qaxis-panel-body {
  position: absolute;
  top: 42px;
  bottom: 0;
  left: 0;
  right: 0;
}
#tm-qaxis-root .tm-qaxis-list {
  height: 100%;
  overflow: auto;
  padding: 8px 8px 10px 8px;

  /* Hide scrollbar but keep scroll */
  scrollbar-width: none;
  -ms-overflow-style: none;
}
#tm-qaxis-root .tm-qaxis-list::-webkit-scrollbar { width: 0; height: 0; }

#tm-qaxis-root .tm-qaxis-item {
  display: flex;
  gap: 10px;
  align-items: flex-start;
  padding: 8px 10px;
  border-radius: 12px;
  cursor: pointer;
  user-select: none;
}
#tm-qaxis-root .tm-qaxis-item:hover {
  background: var(--tm-qaxis-item-hover);
}
#tm-qaxis-root .tm-qaxis-item.active {
  background: var(--tm-qaxis-item-active);
}
#tm-qaxis-root .tm-qaxis-dot {
  width: 10px;
  height: 10px;
  border-radius: 999px;
  margin-top: 2px;
  flex: 0 0 auto;
  background: var(--tm-qaxis-node);
  border: 1px solid var(--tm-qaxis-tooltip-border);
}
#tm-qaxis-root .tm-qaxis-text {
  flex: 1 1 auto;
  font-size: 12px;
  line-height: 1.35;
  color: var(--tm-qaxis-tooltip-fg);
  opacity: 0.92;
  word-break: break-word;
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* Tooltip (small + hidden scrollbar) */
#tm-qaxis-root .tm-qaxis-tooltip {
  position: fixed;
  max-width: 280px;
  max-height: 160px;
  overflow: auto;
  padding: 8px 10px;
  border-radius: 10px;
  background: var(--tm-qaxis-tooltip-bg);
  color: var(--tm-qaxis-tooltip-fg);
  box-shadow: 0 10px 28px var(--tm-qaxis-shadow);
  border: 1px solid var(--tm-qaxis-tooltip-border);
  pointer-events: none;
  opacity: 0;
  transform: translateY(-50%) translateX(6px);
  transition: opacity 140ms ease, transform 140ms ease;
  line-height: 1.35;
  font-size: 12px;
  white-space: pre-wrap;

  /* Hide scrollbar but keep scroll */
  scrollbar-width: none;
  -ms-overflow-style: none;
}
#tm-qaxis-root .tm-qaxis-tooltip::-webkit-scrollbar { width: 0; height: 0; }
#tm-qaxis-root .tm-qaxis-tooltip.show {
  opacity: 1;
  transform: translateY(-50%) translateX(0);
}
#tm-qaxis-root .tm-qaxis-tooltip::after {
  content: "";
  position: absolute;
  top: 50%;
  right: -8px;
  transform: translateY(-50%);
  border-width: 8px 0 8px 8px;
  border-style: solid;
  border-color: transparent transparent transparent var(--tm-qaxis-tooltip-bg);
}

/* Palette */
#tm-qaxis-root .tm-qaxis-palette {
  position: fixed;
  display: none;
  padding: 8px;
  border-radius: 12px;
  background: var(--tm-qaxis-tooltip-bg);
  border: 1px solid var(--tm-qaxis-tooltip-border);
  box-shadow: 0 10px 28px var(--tm-qaxis-shadow);
  pointer-events: auto;
  z-index: 2147483647;
}
#tm-qaxis-root .tm-qaxis-palette.show { display: grid; }
#tm-qaxis-root .tm-qaxis-palette-grid {
  display: grid;
  grid-template-columns: repeat(4, 18px);
  gap: 8px;
  align-items: center;
}
#tm-qaxis-root .tm-qaxis-swatch {
  width: 18px; height: 18px;
  border-radius: 6px;
  border: 1px solid var(--tm-qaxis-tooltip-border);
  cursor: pointer;
}
#tm-qaxis-root .tm-qaxis-swatch.gray { background: var(--tm-qaxis-node); }
#tm-qaxis-root .tm-qaxis-swatch:hover { transform: translateY(-1px); }

/* Theme vars */
#tm-qaxis-root {
  --tm-qaxis-line: rgba(0,0,0,0.46);
  --tm-qaxis-node: rgba(185,185,185,0.92);
  --tm-qaxis-node-hover: rgba(120,120,120,0.92);
  --tm-qaxis-thumb: rgba(0,0,0,0.18);
  --tm-qaxis-thumb-drag: rgba(0,0,0,0.30);
  --tm-qaxis-tooltip-bg: rgba(255,255,255,0.97);
  --tm-qaxis-tooltip-fg: rgba(25,25,25,0.94);
  --tm-qaxis-tooltip-border: rgba(0,0,0,0.10);
  --tm-qaxis-shadow: rgba(0,0,0,0.16);
  --tm-qaxis-pin-ring: rgba(0,0,0,0.18);
  --tm-qaxis-btn-bg: rgba(255,255,255,0.85);
  --tm-qaxis-item-hover: rgba(0,0,0,0.04);
  --tm-qaxis-item-active: rgba(0,0,0,0.07);
}
html.dark #tm-qaxis-root, body.dark #tm-qaxis-root, [data-theme="dark"] #tm-qaxis-root {
  --tm-qaxis-line: rgba(255,255,255,0.50);
  --tm-qaxis-node: rgba(225,225,225,0.86);
  --tm-qaxis-node-hover: rgba(245,245,245,0.92);
  --tm-qaxis-thumb: rgba(255,255,255,0.18);
  --tm-qaxis-thumb-drag: rgba(255,255,255,0.30);
  --tm-qaxis-tooltip-bg: rgba(20,20,20,0.94);
  --tm-qaxis-tooltip-fg: rgba(245,245,245,0.94);
  --tm-qaxis-tooltip-border: rgba(255,255,255,0.12);
  --tm-qaxis-shadow: rgba(0,0,0,0.46);
  --tm-qaxis-pin-ring: rgba(255,255,255,0.18);
  --tm-qaxis-btn-bg: rgba(30,30,30,0.78);
  --tm-qaxis-item-hover: rgba(255,255,255,0.06);
  --tm-qaxis-item-active: rgba(255,255,255,0.10);
}
    `);
  }

  /**********************
   * UI build
   **********************/
  function buildUI() {
    if (state.root) return;

    const root = document.createElement("div");
    root.id = "tm-qaxis-root";
    root.innerHTML = `
      <div class="tm-qaxis-toggle" title="显示/隐藏问题列表">
        <svg width="14" height="14" viewBox="0 0 24 24" fill="none">
          <path d="M4 6H20" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
          <path d="M4 12H20" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
          <path d="M4 18H20" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
        </svg>
      </div>

      <div class="tm-qaxis-panel" aria-hidden="true">
        <div class="tm-qaxis-panel-header">
          <div class="tm-qaxis-panel-title">问题列表(<span class="tm-qaxis-panel-count">0</span>)</div>
          <div class="tm-qaxis-panel-close" title="关闭">
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none">
              <path d="M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
              <path d="M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
            </svg>
          </div>
        </div>
        <div class="tm-qaxis-panel-body">
          <div class="tm-qaxis-list"></div>
        </div>
      </div>

      <div class="tm-qaxis-axis">
        <div class="tm-qaxis-track"></div>
        <div class="tm-qaxis-nodes"></div>
        <div class="tm-qaxis-thumb" title="拖动滚动进度"></div>
      </div>

      <div class="tm-qaxis-tooltip"></div>

      <div class="tm-qaxis-palette">
        <div class="tm-qaxis-palette-grid"></div>
      </div>
    `;
    document.body.appendChild(root);

    state.root = root;
    state.axis = root.querySelector(".tm-qaxis-axis");
    state.nodesLayer = root.querySelector(".tm-qaxis-nodes");
    state.tooltip = root.querySelector(".tm-qaxis-tooltip");
    state.thumb = root.querySelector(".tm-qaxis-thumb");
    state.track = root.querySelector(".tm-qaxis-track");
    state.palette = root.querySelector(".tm-qaxis-palette");

    state.panel = root.querySelector(".tm-qaxis-panel");
    state.panelList = root.querySelector(".tm-qaxis-list");
    state.panelCount = root.querySelector(".tm-qaxis-panel-count");
    state.panelToggleBtn = root.querySelector(".tm-qaxis-toggle");

    state.pinTop = createPinWrap("top");
    state.pinBottom = createPinWrap("bottom");

    state.thumb.addEventListener("pointerdown", onThumbPointerDown, { passive: false });

    buildPalette();

    state.panelToggleBtn.addEventListener("click", (e) => {
      e.preventDefault(); e.stopPropagation();
      togglePanel();
    });
    state.panel.querySelector(".tm-qaxis-panel-close").addEventListener("click", (e) => {
      e.preventDefault(); e.stopPropagation();
      setPanelOpen(false);
    });

    window.addEventListener("keydown", (e) => {
      if (e.key === "Escape" && state.panelOpen) setPanelOpen(false);
    }, { passive: true });

    window.addEventListener("mousedown", (e) => {
      if (!state.palette?.classList.contains("show")) return;
      const path = e.composedPath ? e.composedPath() : [];
      if (path.includes(state.palette)) return;
      hidePalette();
    }, { passive: true });

    window.addEventListener("scroll", () => hidePalette(), { passive: true });
    window.addEventListener("resize", () => hidePalette(), { passive: true });
  }

  function setPanelOpen(open) {
    state.panelOpen = !!open;
    if (state.panelOpen) state.root.classList.add("panel-open");
    else state.root.classList.remove("panel-open");
    state.panel.setAttribute("aria-hidden", state.panelOpen ? "false" : "true");
    updatePanelList(true);
    updateLayout();
  }
  function togglePanel() { setPanelOpen(!state.panelOpen); }

  /**********************
   * Panel list rendering
   **********************/
  function updatePanelList(scrollToActive = false) {
    if (!state.panelList || !state.panelCount) return;

    const qs = state.questions;
    state.panelCount.textContent = String(qs.length);

    // Rebuild (simple + robust)
    const frag = document.createDocumentFragment();

    for (let i = 0; i < qs.length; i++) {
      const q = qs[i];
      const item = document.createElement("div");
      item.className = "tm-qaxis-item";
      item.dataset.index = String(i);
      item.title = q.text || "";

      const dot = document.createElement("div");
      dot.className = "tm-qaxis-dot";
      const hex = getColorHexFor(q.key);
      if (hex) dot.style.background = hex;

      const text = document.createElement("div");
      text.className = "tm-qaxis-text";
      text.textContent = `${i + 1}. ${q.text || ""}`;

      item.appendChild(dot);
      item.appendChild(text);

      item.addEventListener("click", (e) => {
        e.preventDefault(); e.stopPropagation();
        scrollToQuestion(q);
      });

      frag.appendChild(item);
    }

    state.panelList.innerHTML = "";
    state.panelList.appendChild(frag);

    updatePanelActive(scrollToActive);
  }

  function updatePanelActive(scrollToActive = false) {
    if (!state.panelOpen || !state.panelList) return;

    const items = Array.from(state.panelList.querySelectorAll(".tm-qaxis-item"));
    for (const it of items) it.classList.remove("active");

    const idx = clamp(state.currentIdxAll || 0, 0, Math.max(0, state.questions.length - 1));
    const active = items[idx];
    if (active) {
      active.classList.add("active");
      if (scrollToActive) active.scrollIntoView({ block: "nearest" });
    }
  }

  /**********************
   * Palette
   **********************/
  function buildPalette() {
    const grid = state.palette.querySelector(".tm-qaxis-palette-grid");
    grid.innerHTML = "";

    for (const c of COLOR_LIST) {
      const sw = document.createElement("div");
      sw.className = "tm-qaxis-swatch " + (c.key === "gray" ? "gray" : "");
      sw.title = c.label;
      if (c.hex) sw.style.background = c.hex;

      sw.addEventListener("click", () => {
        const key = state.palette.dataset.targetKey;
        if (!key) return;

        state.colorMap[key] = c.key;
        saveColors();

        const q = state.questions.find((x) => x.key === key);
        if (q?.nodeWrap) applyNodeColor(q.nodeWrap.querySelector(".tm-qaxis-node"), c.key);

        updateLayout();
        if (state.panelOpen) updatePanelList(false);
        hidePalette();
      });

      grid.appendChild(sw);
    }
  }

  function showPaletteForKey(key, clientY) {
    const p = state.palette;
    if (!p || !key) return;

    p.dataset.targetKey = key;

    const axisRect = state.axis.getBoundingClientRect();
    const width = 120;
    const left = clamp(axisRect.left - 12 - width, 10, window.innerWidth - width - 10);
    const top = clamp(clientY - 24, 10, window.innerHeight - 110);

    p.style.left = `${left}px`;
    p.style.top = `${top}px`;
    p.classList.add("show");
  }
  function hidePalette() { state.palette?.classList.remove("show"); }

  /**********************
   * Nodes + pins
   **********************/
  function createPinWrap(kind) {
    const wrap = document.createElement("div");
    wrap.className = "tm-qaxis-node-wrap pin";
    wrap.dataset.kind = kind;

    const pill = document.createElement("div");
    pill.className = "tm-qaxis-node";
    wrap.appendChild(pill);

    wrap.addEventListener("mouseenter", () => showTooltipForNode(wrap));
    wrap.addEventListener("mouseleave", () => hideTooltip());

    wrap.addEventListener("click", (e) => {
      e.preventDefault(); e.stopPropagation();
      const key = wrap.dataset.key;
      const qq = state.questions.find((x) => x.key === key);
      if (!qq) return;
      scrollToQuestion(qq);
    });

    wrap.addEventListener("contextmenu", (e) => {
      e.preventDefault(); e.stopPropagation();
      hideTooltip();
      showPaletteForKey(wrap.dataset.key, e.clientY);
    });

    state.nodesLayer.appendChild(wrap);
    return wrap;
  }

  function createNodeWrap(q) {
    const wrap = document.createElement("div");
    wrap.className = "tm-qaxis-node-wrap";
    wrap.dataset.key = q.key;
    wrap.dataset.text = q.text;

    const pill = document.createElement("div");
    pill.className = "tm-qaxis-node";
    wrap.appendChild(pill);

    applyNodeColor(pill, getColorKeyFor(q.key));

    wrap.addEventListener("mouseenter", () => showTooltipForNode(wrap));
    wrap.addEventListener("mouseleave", () => hideTooltip());

    wrap.addEventListener("click", (e) => {
      e.preventDefault(); e.stopPropagation();
      const key = wrap.dataset.key;
      const qq = state.questions.find((x) => x.key === key);
      if (!qq) return;
      scrollToQuestion(qq);
    });

    wrap.addEventListener("contextmenu", (e) => {
      e.preventDefault(); e.stopPropagation();
      hideTooltip();
      showPaletteForKey(wrap.dataset.key, e.clientY);
    });

    state.nodesLayer.appendChild(wrap);
    return wrap;
  }

  /**********************
   * Tooltip
   **********************/
  function showTooltipForNode(nodeWrap) {
    const t = state.tooltip;
    if (!t) return;

    const text = nodeWrap.dataset.text || "";
    if (!text.trim()) return;

    t.textContent = text;

    const nr = nodeWrap.getBoundingClientRect();
    const axisRect = state.axis.getBoundingClientRect();

    const margin = 14;
    const maxRight = axisRect.left - margin;

    const bubbleWidth = Math.min(280, Math.max(180, maxRight - 16));
    t.style.width = bubbleWidth + "px";

    const centerY = nr.top + nr.height / 2;
    t.style.left = Math.max(10, maxRight - bubbleWidth) + "px";

    t.classList.add("show");
    const th = t.getBoundingClientRect().height;
    const clampedCenterY = clamp(centerY, 10 + th / 2, window.innerHeight - 10 - th / 2);
    t.style.top = clampedCenterY + "px";
  }
  function hideTooltip() { state.tooltip?.classList.remove("show"); }

  /**********************
   * Extraction + reconcile
   **********************/
  function extractQuestions() {
    const blocks = getUserMessageBlocks();
    const out = [];

    blocks.forEach((container, idx) => {
      const mid =
        container.getAttribute("data-message-id") ||
        container.closest("[data-message-id]")?.getAttribute("data-message-id") ||
        null;

      const markdown = container.querySelector(".markdown");
      const text = (markdown?.innerText || container.innerText || "").trim();
      if (!text) return;

      if (NODE_MODE === "questions_only" && !isLikelyQuestion(text)) return;

      const key = mid ? `mid_${mid}` : `idx_${idx}_${hash32(text).toString(16)}`;
      out.push({ key, text, el: container, offset: 0, nodeWrap: null });
    });

    return out;
  }

  function reconcileNodes(newQuestions) {
    const existingByKey = new Map(state.questions.map((q) => [q.key, q]));

    for (const q of state.questions) {
      if (!newQuestions.find((nq) => nq.key === q.key)) {
        q.nodeWrap?.parentElement?.removeChild(q.nodeWrap);
      }
    }

    newQuestions.forEach((q) => {
      const old = existingByKey.get(q.key);
      q.nodeWrap = old?.nodeWrap || createNodeWrap(q);
      q.nodeWrap.dataset.text = q.text;
      q.nodeWrap.dataset.key = q.key;
      q.nodeWrap.dataset.kind = "normal";
    });

    state.questions = newQuestions;
  }

  function computeOffsets() {
    const sc = state.scrollContainer || window;
    for (const q of state.questions) q.offset = getOffsetToScrollContainer(q.el, sc);
  }

  /**********************
   * Scroll / thumb drag
   **********************/
  function attachScrollListener() {
    detachScrollListener();
    const sc = state.scrollContainer;

    const onScroll = () => {
      if (state.rafScroll) return;
      state.rafScroll = requestAnimationFrame(() => {
        state.rafScroll = 0;
        updateLayout();
      });
    };
    state._onScroll = onScroll;

    if (sc === window) window.addEventListener("scroll", onScroll, { passive: true });
    else sc.addEventListener("scroll", onScroll, { passive: true });
  }
  function detachScrollListener() {
    const sc = state.scrollContainer;
    const onScroll = state._onScroll;
    if (!onScroll) return;
    if (sc === window) window.removeEventListener("scroll", onScroll);
    else sc?.removeEventListener("scroll", onScroll);
    state._onScroll = null;
  }

  function onThumbPointerDown(e) {
    e.preventDefault();
    e.stopPropagation();
    hidePalette();

    state.dragging = true;
    state.thumb.classList.add("dragging");
    state.thumb.setPointerCapture(e.pointerId);

    const move = (ev) => {
      if (!state.dragging) return;
      ev.preventDefault();

      const axisRect = state.axis.getBoundingClientRect();
      const thumbH = state.thumb.getBoundingClientRect().height;
      const axisH = axisRect.height;
      const usable = Math.max(1, axisH - thumbH);

      const y = (ev.clientY - axisRect.top) - thumbH / 2;
      const ratio = clamp(y / usable, 0, 1);

      const m = getScrollMetrics(state.scrollContainer || window);
      setScrollTop(state.scrollContainer || window, ratio * m.maxScrollTop);

      state.thumb.style.top = `${ratio * usable + thumbH / 2}px`;
      updateLayout();
    };

    const up = (ev) => {
      state.dragging = false;
      state.thumb.classList.remove("dragging");
      try { state.thumb.releasePointerCapture(ev.pointerId); } catch {}
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
      updateLayout();
    };

    window.addEventListener("pointermove", move, { passive: false });
    window.addEventListener("pointerup", up, { passive: true });
  }

  function scrollToQuestion(q) {
    const sc = state.scrollContainer || window;
    const m = getScrollMetrics(sc);
    const target = clamp(q.offset - 24, 0, m.maxScrollTop);
    if (sc === window) window.scrollTo({ top: target, behavior: "smooth" });
    else sc.scrollTo({ top: target, behavior: "smooth" });
  }

  /**********************
   * Layout helpers
   **********************/
  function findCurrentIndex(qs, scrollTop, clientHeight) {
    const center = scrollTop + clientHeight * SCROLL_CENTER_BIAS;
    let nearest = 0, best = Infinity;
    for (let i = 0; i < qs.length; i++) {
      const d = Math.abs(qs[i].offset - center);
      if (d < best) { best = d; nearest = i; }
    }
    return nearest;
  }

  function computeWindow(regularCount, currentIdxRegular, slots) {
    if (regularCount <= 0 || slots <= 0) return { start: 0, end: 0 };
    const win = Math.min(slots, regularCount);
    let end = clamp(currentIdxRegular + 1, win, regularCount);
    let start = end - win;
    return { start, end };
  }

  // Top pin: marked-first; else first question if not visible
  function pickTopPinIndex(qs, start) {
    for (let i = start - 1; i >= 0; i--) {
      if (isMarked(qs[i].key)) return i;
    }
    if (start > 0) return 0;
    return -1;
  }

  /**********************
   * Layout
   **********************/
  function updateLayout() {
    if (!state.root || !state.axis) return;

    if (!state.questions.length) {
      state.root.style.display = "none";
      return;
    }
    state.root.style.display = "";

    const sc = state.scrollContainer || window;
    const m = getScrollMetrics(sc);

    computeOffsets();

    const axisH = state.axis.getBoundingClientRect().height;
    const nodeWrapH = 34;

    // Pin positions
    const pinYTop = nodeWrapH / 2;
    const pinYBottom = axisH - nodeWrapH / 2;
    state.pinTop.style.top = `${pinYTop}px`;
    state.pinBottom.style.top = `${pinYBottom}px`;

    const qs = state.questions;
    const n = qs.length;

    // Current index among all nodes (for thumb + panel highlight)
    state.currentIdxAll = findCurrentIndex(qs, m.scrollTop, m.clientHeight);
    updatePanelActive(false);

    // Bottom pin -> last
    const last = qs[n - 1];
    state.pinBottom.style.display = n ? "" : "none";
    if (n) {
      state.pinBottom.dataset.key = last.key;
      state.pinBottom.dataset.text = last.text || "跳到最后一条";
      applyNodeColor(state.pinBottom.querySelector(".tm-qaxis-node"), getColorKeyFor(last.key));
    }

    // Hide all normals
    for (let i = 0; i < n; i++) qs[i].nodeWrap?.classList.add("hidden");

    // Regular nodes exclude last
    const regularCount = Math.max(0, n - 1);
    if (regularCount === 0) {
      state.pinTop.style.display = "none";
      if (!state.dragging) state.thumb.style.top = `${pinYBottom}px`;
      return;
    }

    const currentIdxRegular = clamp(
      findCurrentIndex(qs.slice(0, n - 1), m.scrollTop, m.clientHeight),
      0,
      regularCount - 1
    );

    const baseSlots = Math.max(0, TOTAL_VISIBLE_NODES - 1); // bottom pin always takes 1

    // Try without top pin
    let slots = baseSlots;
    let { start, end } = computeWindow(regularCount, currentIdxRegular, slots);
    let topPinIdx = pickTopPinIndex(qs, start);
    let topPinShown = false;

    // If top pin needed and affordable, recompute with one fewer slot
    if (topPinIdx >= 0 && baseSlots >= 2) {
      const slots2 = baseSlots - 1;
      const w2 = computeWindow(regularCount, currentIdxRegular, slots2);
      const t2 = pickTopPinIndex(qs, w2.start);
      if (t2 >= 0) {
        topPinShown = true;
        slots = slots2;
        start = w2.start;
        end = w2.end;
        topPinIdx = t2;
      }
    }

    // Show/hide top pin
    if (topPinShown) {
      const t = qs[topPinIdx];
      state.pinTop.style.display = "";
      state.pinTop.dataset.key = t.key;
      state.pinTop.dataset.text = t.text || "跳到目标节点";
      applyNodeColor(state.pinTop.querySelector(".tm-qaxis-node"), getColorKeyFor(t.key));
    } else {
      state.pinTop.style.display = "none";
      state.pinTop.dataset.key = "";
      state.pinTop.dataset.text = "";
      topPinIdx = -1;
    }

    // Even distribution area
    const topReserve = topPinShown ? nodeWrapH : 0;
    const minY = topReserve + nodeWrapH / 2;
    const maxY = axisH - nodeWrapH - nodeWrapH / 2;
    const span = Math.max(1, maxY - minY);

    const win = Math.max(0, end - start);
    for (let k = 0; k < win; k++) {
      const i = start + k;
      const q = qs[i];
      const wrap = q.nodeWrap;
      if (!wrap) continue;

      wrap.classList.remove("hidden");

      const t = (k + 1) / (win + 1);
      wrap.style.top = `${minY + t * span}px`;
      wrap.style.opacity = "1";

      applyNodeColor(wrap.querySelector(".tm-qaxis-node"), getColorKeyFor(q.key));
    }

    // Ensure last hidden (represented by bottom pin)
    qs[n - 1]?.nodeWrap?.classList.add("hidden");

    // Thumb positioning: drag -> ratio; else anchor near current node position
    const thumbH = state.thumb.getBoundingClientRect().height || 58;
    const usableThumb = Math.max(1, axisH - thumbH);

    if (state.dragging) {
      const ratio = clamp(m.scrollTop / m.maxScrollTop, 0, 1);
      state.thumb.style.top = `${ratio * usableThumb + thumbH / 2}px`;
      return;
    }

    let thumbY = NaN;
    const currentIdxAll = state.currentIdxAll;

    if (currentIdxAll === n - 1) {
      thumbY = pinYBottom;
    } else {
      if (currentIdxAll >= start && currentIdxAll < end) {
        const y = pxToNum(qs[currentIdxAll]?.nodeWrap?.style?.top);
        if (Number.isFinite(y)) thumbY = y;
      } else if (currentIdxAll < start) {
        thumbY = topPinShown ? pinYTop : pxToNum(qs[start]?.nodeWrap?.style?.top);
      } else {
        const y = pxToNum(qs[end - 1]?.nodeWrap?.style?.top);
        if (Number.isFinite(y)) thumbY = y;
      }
    }

    if (!Number.isFinite(thumbY)) {
      const ratio = clamp(m.scrollTop / m.maxScrollTop, 0, 1);
      thumbY = ratio * usableThumb + thumbH / 2;
    }

    state.thumb.style.top = `${clamp(thumbY, thumbH / 2, axisH - thumbH / 2)}px`;
  }

  /**********************
   * Rescan + observers
   **********************/
  function rescan() {
    if (state.lastUrl !== location.href) {
      state.lastUrl = location.href;
      loadColors();
      hidePalette();

      if (state.nodesLayer) {
        state.nodesLayer.innerHTML = "";
        state.pinTop = createPinWrap("top");
        state.pinBottom = createPinWrap("bottom");
      }
      state.questions = [];
    }

    const sc = findScrollContainer();
    if (sc !== state.scrollContainer) {
      detachScrollListener();
      state.scrollContainer = sc;
      attachScrollListener();
    }

    const newQs = extractQuestions();
    reconcileNodes(newQs);

    if (!state.questions.length) {
      state.root && (state.root.style.display = "none");
      return;
    }

    updateLayout();
    if (state.panelOpen) updatePanelList(false);
  }

  function installObservers() {
    const mo = new MutationObserver(() => debounceRescan(450));
    mo.observe(document.body, { childList: true, subtree: true });

    window.addEventListener("resize", () => updateLayout(), { passive: true });

    setInterval(() => {
      if (state.lastUrl !== location.href) rescan();
    }, 800);
  }

  /**********************
   * Init
   **********************/
  function init() {
    injectCSS();
    buildUI();
    loadColors();
    state.lastUrl = location.href;

    installObservers();

    let tries = 0;
    const timer = setInterval(() => {
      tries++;
      rescan();
      if (state.questions.length || tries > 20) clearInterval(timer);
    }, 500);
  }

  init();
})();