GPT Branch Tree Navigator (Preview + Jump)

树状分支 + 预览 + 一键跳转;支持隐藏与悬浮按钮恢复;快捷键 Alt+T;/ 聚焦搜索、Esc 关闭;拖拽移动面板;渐进式渲染;Markdown 预览;防抖监听;修复:当前分支已渲染却被误判为“未在该分支”。

// ==UserScript==
// @name         GPT Branch Tree Navigator (Preview + Jump)
// @namespace    jiaoling.tools.gpt.tree
// @version      1.6.0
// @description  树状分支 + 预览 + 一键跳转;支持隐藏与悬浮按钮恢复;快捷键 Alt+T;/ 聚焦搜索、Esc 关闭;拖拽移动面板;渐进式渲染;Markdown 预览;防抖监听;修复:当前分支已渲染却被误判为“未在该分支”。
// @author       Jiaoling
// @match        https://chat.openai.com/*
// @match        https://chatgpt.com/*
// @grant        GM_addStyle
// @run-at       document-idle
// ==/UserScript==

(() => {
  "use strict";

  /** *************************** 配置 *************************** */
  const CONFIG = Object.freeze({
    PANEL_WIDTH_MIN: 350,
    PANEL_WIDTH_MAX: 500,
    PANEL_WIDTH_DEFAULT: 400,
    PANEL_WIDTH_STEP: 1,
    CARD_WIDTH_MAX: 400,
    CARD_INDENT: 25,
    PREVIEW_FULL_LINES: 2,
    PREVIEW_TAIL_CHARS: 10,
    HIGHLIGHT_MS: 1400,
    SCROLL_OFFSET: 80,
    LS_KEY: "gtt_prefs_v3",
    RENDER_CHUNK: 120,
    RENDER_IDLE_MS: 12,
    OBS_DEBOUNCE_MS: 250,
    SIG_TEXT_LEN: 200,
    SELECTORS: {
      scrollRoot: "main",
      messageBlocks: [
        "[data-message-author-role]",
        "article:has(.markdown)",
        "main [data-testid^=\"conversation-turn\"]",
        "main .group.w-full",
        "main [data-message-id]"
      ].join(","),
      messageText: [
        ".markdown", ".prose",
        "[data-message-author-role] .whitespace-pre-wrap",
        "[data-message-author-role]"
      ].join(","),
    },
    ENDPOINTS: (cid) => ({
      get: [
        `/backend-api/conversation/${cid}`,
        `/backend-api/conversation/${cid}/`,
      ]
    })
  });

  /** *************************** 样式 *************************** */
  class StyleManager {
    static ensure(cssText) {
      try {
        GM_addStyle(cssText);
      } catch (_) {
        const style = document.createElement("style");
        style.textContent = cssText;
        document.head.appendChild(style);
      }
    }
  }

  const PANEL_STYLE = `
    :root{--gtt-cur:#fa8c16;}
    #gtt-panel{
      position:fixed;top:64px;right:12px;z-index:999999;
      width:min(var(--gtt-panel-width, ${CONFIG.PANEL_WIDTH_DEFAULT}px), calc(100vw - 24px));
      max-width:min(${CONFIG.PANEL_WIDTH_MAX}px, calc(100vw - 24px));
      min-width:min(${CONFIG.PANEL_WIDTH_MIN}px, calc(100vw - 24px));
      max-height:calc(100vh - 84px);display:flex;flex-direction:column;overflow:hidden;
      border-radius:12px;border:1px solid var(--gtt-bd,#d0d7de);background:var(--gtt-bg,#fff);
      box-shadow:0 8px 28px rgba(0,0,0,.18);font:13px/1.4 system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Arial;
      user-select:none
    }
    #gtt-header{display:flex;gap:8px;align-items:center;padding:10px 10px 10px 18px;border-bottom:1px solid var(--gtt-bd,#d0d7de);background:var(--gtt-hd,#f6f8fa)}
    #gtt-header .title{font-weight:700;flex:1;cursor:move}
    #gtt-header .btn{border:1px solid var(--gtt-bd,#d0d7de);background:#fff;cursor:pointer;padding:4px 8px;border-radius:8px;font-size:12px}
    #gtt-body{display:flex;flex-direction:column;min-height:0;flex:1 1 auto}
    #gtt-search{margin:8px 10px 8px 18px;padding:6px 8px;border:1px solid var(--gtt-bd,#d0d7de);border-radius:8px;width:calc(100% - 28px);outline:none;background:var(--gtt-bg,#fff)}
    #gtt-resize{position:absolute;top:0;left:0;width:8px;height:100%;cursor:ew-resize;display:flex;align-items:center;justify-content:center;z-index:1;touch-action:none}
    #gtt-resize::after{content:'';width:2px;height:32px;border-radius:1px;background:var(--gtt-bd,#d0d7de);opacity:.55;transition:opacity .2s ease}
    #gtt-resize:hover::after{opacity:.85}
    #gtt-pref{display:flex;gap:10px;align-items:center;padding:0 10px 8px 18px;color:#555;flex-wrap:wrap}
    #gtt-pref .gtt-pref-row{display:flex;align-items:center;gap:8px;flex:1 1 100%;font-size:12px}
    #gtt-pref .gtt-pref-title{white-space:nowrap;opacity:.8}
    #gtt-pref .gtt-pref-value{min-width:44px;text-align:right;opacity:.8}
    #gtt-pref input[type="range"]{flex:1 1 auto}
    #gtt-pref .gtt-pref-reset{border:1px solid var(--gtt-bd,#d0d7de);background:var(--gtt-bg,#fff);color:inherit;padding:2px 6px;border-radius:6px;font-size:11px;cursor:pointer}
    #gtt-tree{overflow:auto;overflow-x:auto;padding:8px 12px 10px 18px;max-width:calc(${CONFIG.PANEL_WIDTH_MAX}px - 30px);flex:1 1 auto;min-height:0;width:100%;max-height:var(--gtt-tree-max-height,360px)}
    .gtt-node{padding:6px 8px;border-radius:8px;margin:2px 0;cursor:pointer;position:relative;display:flex;flex-direction:column;gap:4px;width:100%;max-width:${CONFIG.CARD_WIDTH_MAX}px;flex-shrink:0;box-sizing:border-box}
    .gtt-node:hover{background:rgba(127,127,255,.08)}
    .gtt-node .head{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
    .gtt-node .badge{display:inline-flex;align-items:center;justify-content:center;font-size:10px;padding:1px 5px;border-radius:6px;border:1px solid var(--gtt-bd,#d0d7de);opacity:.75;min-width:18px}
    .gtt-node .title{font-weight:600;word-break:break-word;flex:1 1 auto}
    .gtt-node .meta{opacity:.65;font-size:10px;margin-left:auto;white-space:nowrap}
    .gtt-node .pv{display:flex;flex-direction:column;gap:2px;opacity:.88;margin:0;white-space:normal;word-break:break-word}
    .gtt-node .pv-line{display:block}
    .gtt-node .pv-line-more{font-size:12px;opacity:.7}
    .gtt-children{margin-left:${CONFIG.CARD_INDENT}px;border-left:1px dashed var(--gtt-bd,#d0d7de);padding-left:10px}
    .gtt-hidden{display:none!important}
    .gtt-highlight{outline:3px solid rgba(88,101,242,.65)!important;transition:outline-color .6s ease}
    .gtt-node.gtt-current{background:rgba(250,140,22,.12);border-left:2px solid var(--gtt-cur,#fa8c16);padding-left:10px}
    .gtt-node.gtt-current .badge{border-color:var(--gtt-cur,#fa8c16);color:var(--gtt-cur,#fa8c16);opacity:1}
    .gtt-node.gtt-current-leaf{box-shadow:0 0 0 2px rgba(250,140,22,.24) inset}
    .gtt-children.gtt-current-line{border-left:2px dashed var(--gtt-cur,#fa8c16)}
    #gtt-modal{position:fixed;inset:0;z-index:1000000;background:rgba(0,0,0,.42);display:none;align-items:center;justify-content:center}
    #gtt-modal .card{max-width:880px;max-height:80vh;overflow:auto;background:var(--gtt-bg,#fff);border:1px solid var(--gtt-bd,#d0d7de);border-radius:12px;box-shadow:0 8px 28px rgba(0,0,0,.25)}
    #gtt-modal .hd{display:flex;align-items:center;gap:8px;padding:10px;border-bottom:1px solid var(--gtt-bd,#d0d7de);background:var(--gtt-hd,#f6f8fa);position:sticky;top:0;z-index:1}
    #gtt-modal .bd{padding:12px 16px;font-size:14px;line-height:1.65;overflow-x:auto}
    #gtt-modal .bd p{margin:0 0 10px}
    #gtt-modal .bd h1,#gtt-modal .bd h2,#gtt-modal .bd h3,#gtt-modal .bd h4,#gtt-modal .bd h5,#gtt-modal .bd h6{margin:18px 0 10px;font-weight:600}
    #gtt-modal .bd pre{background:rgba(99,110,123,.08);padding:10px 12px;border-radius:8px;margin:12px 0;font-family:SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:13px;line-height:1.55;white-space:pre;overflow:auto}
    #gtt-modal .bd code{background:rgba(99,110,123,.2);padding:1px 4px;border-radius:4px;font-family:SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:13px}
    #gtt-modal .bd pre code{background:transparent;padding:0}
    #gtt-modal .bd ul{margin:0 0 12px 18px;padding:0 0 0 12px}
    #gtt-modal .bd li{margin:4px 0}
    #gtt-modal .btn{border:1px solid var(--gtt-bd,#d0d7de);background:#fff;cursor:pointer;padding:4px 8px;border-radius:8px;font-size:12px}
    #gtt-fab{
      position:fixed;right:12px;bottom:16px;z-index:999999;display:none;align-items:center;gap:8px;
      padding:8px 12px;border-radius:999px;border:1px solid var(--gtt-bd,#d0d7de);
      background:var(--gtt-bg,#fff);box-shadow:0 8px 28px rgba(0,0,0,.18);cursor:pointer
    }
    #gtt-fab .dot{width:8px;height:8px;border-radius:50%;background:#5865f2}
    #gtt-fab .txt{font-weight:600}
    @media (prefers-color-scheme: dark){
      :root{--gtt-bg:#0b0e14;--gtt-hd:#0f131a;--gtt-bd:#2b3240;--gtt-cur:#f59b4c;color-scheme:dark}
      #gtt-header .btn,#gtt-modal .btn,#gtt-fab{background:#0b0e14;color:#d1d7e0}
      .gtt-node:hover{background:rgba(120,152,255,.12)}
      .gtt-node.gtt-current{background:rgba(250,140,22,.18)}
    }
  `;

  StyleManager.ensure(PANEL_STYLE);

  /** *************************** 基础工具 *************************** */
  class DOMUtils {
    static query(selector, root = document) { return root.querySelector(selector); }
    static queryAll(selector, root = document) { return Array.from(root.querySelectorAll(selector)); }
  }

  class TextUtils {
    static normalize(value) {
      return (value || "").replace(/\u200b/g, "").replace(/\s+/g, " ").trim();
    }
    static normalizeForPreview(value) {
      return (value || "").replace(/\u200b/g, "").replace(/\r\n?/g, "\n");
    }
    static truncateUnits(value, length) {
      if (!value) return "";
      if (!Number.isFinite(length) || length <= 0) return value;
      const units = Array.from(value);
      if (units.length <= length) return value;
      return units.slice(0, length).join("");
    }
  }

  class HashUtils {
    static of(value) {
      const input = value || "";
      let hash = 0;
      for (let i = 0; i < input.length; i++) {
        hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
      }
      return (hash >>> 0).toString(36);
    }
  }

  class HTMLUtils {
    static get ESCAPES() {
      return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
    }
    static escape(text = "") {
      return text.replace(/[&<>'"]/g, (ch) => HTMLUtils.ESCAPES[ch] || ch);
    }
    static escapeAttr(text = "") {
      return HTMLUtils.escape(text).replace(/`/g, "&#96;");
    }
    static formatInline(text = "") {
      const holders = [];
      let out = HTMLUtils.escape(text)
        .replace(/`([^`]+)`/g, (_m, code) => `<code>${code}</code>`)
        .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, url) => `<a href="${HTMLUtils.escapeAttr(url)}" target="_blank" rel="noreferrer noopener">${label}</a>`)
        .replace(/<code>[^<]*<\/code>/g, (match) => { holders.push(match); return `\uFFF0${holders.length - 1}\uFFF1`; })
        .replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>")
        .replace(/__([^_\n]+)__/g, "<strong>$1</strong>")
        .replace(/(\s|^)\*([^*\n]+)\*(?=\s|[\.,!?:;\)\]\}“”"'`]|$)/g, (_m, pre, body) => `${pre}<em>${body}</em>`)
        .replace(/(\s|^)_(?!_)([^_\n]+)_(?=\s|[\.,!?:;\)\]\}“”"'`]|$)/g, (_m, pre, body) => `${pre}<em>${body}</em>`);
      out = out.replace(/\uFFF0(\d+)\uFFF1/g, (_m, idx) => holders[Number(idx)]);
      return out;
    }
  }

  class MarkdownRenderer {
    static renderLite(raw = "") {
      const text = TextUtils.normalizeForPreview(raw || "").trimEnd();
      if (!text) return "<p>(空)</p>";
      const lines = text.split("\n");
      const html = [];
      let inList = false;
      let codeBuffer = null;
      let codeLang = "";
      const flushList = () => {
        if (inList) html.push("</ul>");
        inList = false;
      };
      const flushCode = () => {
        if (!codeBuffer) return;
        const cls = codeLang ? ` class="lang-${HTMLUtils.escapeAttr(codeLang)}"` : "";
        const body = codeBuffer.map(HTMLUtils.escape).join("\n");
        html.push(`<pre><code${cls}>${body}</code></pre>`);
        codeBuffer = null;
        codeLang = "";
      };
      for (const line of lines) {
        const trimmed = line.trim();
        if (/^```/.test(trimmed)) {
          if (codeBuffer) {
            flushCode();
          } else {
            flushList();
            codeBuffer = [];
            codeLang = trimmed.slice(3).trim();
          }
          continue;
        }
        if (codeBuffer) {
          codeBuffer.push(line);
          continue;
        }
        if (!trimmed) {
          flushList();
          html.push("<br>");
          continue;
        }
        const heading = trimmed.match(/^(#{1,6})\s+(.*)$/);
        if (heading) {
          flushList();
          const level = heading[1].length;
          html.push(`<h${level}>${HTMLUtils.formatInline(heading[2])}</h${level}>`);
          continue;
        }
        const listItem = line.match(/^\s*[-*+]\s+(.*)$/);
        if (listItem) {
          if (!inList) {
            html.push("<ul>");
            inList = true;
          }
          html.push(`<li>${HTMLUtils.formatInline(listItem[1])}</li>`);
          continue;
        }
        flushList();
        html.push(`<p>${HTMLUtils.formatInline(line)}</p>`);
      }
      flushCode();
      flushList();
      return html.join("");
    }
  }

  class PreviewBuilder {
    static lines(rawText) {
      const normalized = TextUtils
        .normalizeForPreview(rawText || "")
        .split("\n")
        .map((segment) => TextUtils.normalize(segment))
        .filter(Boolean);
      if (!normalized.length) return ["(空)"];
      const take = CONFIG.PREVIEW_FULL_LINES > 0
        ? Math.min(CONFIG.PREVIEW_FULL_LINES, normalized.length)
        : Math.min(2, normalized.length);
      const result = [];
      for (let i = 0; i < take; i++) {
        result.push(normalized[i]);
      }
      if (normalized.length > take) {
        const rest = TextUtils.normalize(normalized.slice(take).join(" "));
        if (rest) {
          const snippet = CONFIG.PREVIEW_TAIL_CHARS > 0
            ? TextUtils.truncateUnits(rest, CONFIG.PREVIEW_TAIL_CHARS)
            : "";
          result.push(`${snippet}...`);
        }
      }
      return result;
    }
  }

  class PreferenceStore {
    constructor(key, defaults) {
      this.key = key;
      this.defaults = { ...defaults };
      this.state = this.load();
    }
    load() {
      try {
        const raw = localStorage.getItem(this.key) || localStorage.getItem("gtt_prefs_v2");
        const parsed = raw ? JSON.parse(raw) : {};
        return { ...this.defaults, ...parsed };
      } catch (_) {
        return { ...this.defaults };
      }
    }
    snapshot() { return { ...this.state }; }
    get(key) { return this.state[key]; }
    set(key, value, { silent = false } = {}) {
      this.state = { ...this.state, [key]: value };
      if (!silent) this.save();
    }
    assign(patch, { silent = false } = {}) {
      this.state = { ...this.state, ...patch };
      if (!silent) this.save();
    }
    save() {
      try {
        localStorage.setItem(this.key, JSON.stringify(this.state));
      } catch (_) {
        /* ignore */
      }
    }
  }

  class IdleScheduler {
    static rafIdle(fn, ms = CONFIG.RENDER_IDLE_MS) {
      return setTimeout(fn, ms);
    }
    static debounce(fn, wait) {
      let timer = null;
      return (...args) => {
        clearTimeout(timer);
        timer = setTimeout(() => fn(...args), wait);
      };
    }
  }

  class Signature {
    static create(role, text) {
      return (role || "assistant") + "|" + HashUtils.of(TextUtils.normalize(text).slice(0, CONFIG.SIG_TEXT_LEN));
    }
  }

  class BranchState {
    constructor() {
      this.reset();
    }
    reset() {
      this.mapping = null;
      this.latestLinear = [];
      this.domBySig = new Map();
      this.domById = new Map();
      this.currentBranchIds = new Set();
      this.currentBranchSigs = new Set();
      this.currentBranchLeafId = null;
      this.currentBranchLeafSig = null;
    }
    setMapping(mapping) {
      this.mapping = mapping || null;
    }
    collectFromDom() {
      const blocks = DOMUtils.queryAll(CONFIG.SELECTORS.messageBlocks);
      const result = [];
      const ids = new Set();
      const sigs = new Set();
      const domBySig = new Map();
      const domById = new Map();
      for (const el of blocks) {
        const textEl = DOMUtils.query(CONFIG.SELECTORS.messageText, el) || el;
        const raw = (textEl?.innerText || "").trim();
        const text = TextUtils.normalize(raw);
        if (!text) continue;
        let role = el.getAttribute("data-message-author-role");
        if (!role) role = el.querySelector(".markdown,.prose") ? "assistant" : "user";
        const messageId = el.getAttribute("data-message-id")
          || el.dataset?.messageId
          || DOMUtils.query("[data-message-id]", el)?.getAttribute("data-message-id")
          || (el.id?.startsWith("conversation-turn-") ? el.id.split("conversation-turn-")[1] : null);
        const id = messageId ? messageId : (`lin-${HashUtils.of(text.slice(0, 80))}`);
        const sig = Signature.create(role, text);
        const record = { id, role, text, sig, _el: el };
        result.push(record);
        domBySig.set(sig, el);
        if (messageId) domById.set(messageId, el);
        ids.add(id);
        sigs.add(sig);
      }
      this.domBySig = domBySig;
      this.domById = domById;
      this.currentBranchIds = ids;
      this.currentBranchSigs = sigs;
      if (result.length) {
        const leaf = result[result.length - 1];
        this.currentBranchLeafId = leaf?.id || null;
        this.currentBranchLeafSig = leaf?.sig || null;
      } else {
        this.currentBranchLeafId = null;
        this.currentBranchLeafSig = null;
      }
      this.latestLinear = result;
      return result;
    }
    applyHighlight(rootEl) {
      if (!rootEl) return;
      const nodeEls = rootEl.querySelectorAll(".gtt-node");
      const connectorEls = rootEl.querySelectorAll(".gtt-children");
      nodeEls.forEach((el) => el.classList.remove("gtt-current", "gtt-current-leaf"));
      connectorEls.forEach((el) => el.classList.remove("gtt-current-line"));
      const hasBranch = this.currentBranchIds.size || this.currentBranchSigs.size;
      if (!hasBranch) return;
      nodeEls.forEach((el) => {
        const id = el.dataset?.nodeId;
        const sig = el.dataset?.sig;
        const chainIds = Array.isArray(el._chainIds) ? el._chainIds : null;
        const chainSigs = Array.isArray(el._chainSigs) ? el._chainSigs : null;
        const matchesId = id && this.currentBranchIds.has(id);
        const matchesSig = sig && this.currentBranchSigs.has(sig);
        const matchesChainId = chainIds ? chainIds.some((cid) => this.currentBranchIds.has(cid)) : false;
        const matchesChainSig = chainSigs ? chainSigs.some((cs) => this.currentBranchSigs.has(cs)) : false;
        const isCurrent = matchesId || matchesSig || matchesChainId || matchesChainSig;
        if (!isCurrent) return;
        el.classList.add("gtt-current");
        const isLeaf = (
          (this.currentBranchLeafId && (id === this.currentBranchLeafId || (chainIds && chainIds.includes(this.currentBranchLeafId))))
          || (this.currentBranchLeafSig && (sig === this.currentBranchLeafSig || (chainSigs && chainSigs.includes(this.currentBranchLeafSig))))
        );
        if (isLeaf) el.classList.add("gtt-current-leaf");
        const parent = el.parentElement;
        if (parent?.classList?.contains("gtt-children")) {
          parent.classList.add("gtt-current-line");
        }
      });
    }
  }

  class Navigator {
    constructor(branchState, panel) {
      this.branchState = branchState;
      this.panel = panel;
      this.SCROLLABLE_VALUES = new Set(["auto", "scroll", "overlay"]);
    }
    openModal(text, reason) {
      this.panel.showModal(text, reason);
    }
    closeModal() {
      this.panel.hideModal();
    }
    findScrollContainer(el) {
      const rootSel = CONFIG.SELECTORS?.scrollRoot;
      if (rootSel) {
        const root = document.querySelector(rootSel);
        if (root && root.contains(el) && root.scrollHeight > root.clientHeight + 8) {
          return root;
        }
      }
      let cur = el?.parentElement;
      while (cur && cur !== document.body) {
        const style = getComputedStyle(cur);
        if ((this.SCROLLABLE_VALUES.has(style.overflowY) || this.SCROLLABLE_VALUES.has(style.overflow)) && cur.scrollHeight > cur.clientHeight + 8) {
          return cur;
        }
        cur = cur.parentElement;
      }
      return document.scrollingElement || document.documentElement;
    }
    scrollToEl(el) {
      if (!el) return;
      const container = this.findScrollContainer(el);
      if (container && container !== document.body && container !== document.documentElement) {
        const rect = el.getBoundingClientRect();
        const parentRect = container.getBoundingClientRect();
        const offset = rect.top - parentRect.top + container.scrollTop - CONFIG.SCROLL_OFFSET;
        container.scrollTo({ top: offset, behavior: "smooth" });
      } else {
        const offset = el.getBoundingClientRect().top + window.scrollY - CONFIG.SCROLL_OFFSET;
        window.scrollTo({ top: offset, behavior: "smooth" });
      }
      el.classList.add("gtt-highlight");
      setTimeout(() => el.classList.remove("gtt-highlight"), CONFIG.HIGHLIGHT_MS);
    }
    locateByText(text) {
      const snippet = TextUtils.normalize(text).slice(0, 120);
      if (!snippet) return null;
      const blocks = DOMUtils.queryAll(CONFIG.SELECTORS.messageBlocks);
      let best = null;
      let score = -1;
      for (const el of blocks) {
        const textEl = DOMUtils.query(CONFIG.SELECTORS.messageText, el) || el;
        const normalized = TextUtils.normalize(textEl?.innerText || "");
        const idx = normalized.indexOf(snippet);
        if (idx >= 0) {
          const sc = 3000 - idx + Math.min(120, snippet.length);
          if (sc > score) {
            score = sc;
            best = el;
          }
        }
      }
      return best;
    }
    async jumpTo(node) {
      if (!node) return;
      let target = this.branchState.domById.get(node.id);
      if (target && target.isConnected) return this.scrollToEl(target);
      const sig = node.sig || Signature.create(node.role, node.text);
      target = this.branchState.domBySig.get(sig);
      if (target && target.isConnected) return this.scrollToEl(target);
      target = this.locateByText(node.text);
      if (target) return this.scrollToEl(target);
      this.openModal(node.text || "(无文本)", "节点预览(未能定位到页面元素,已为你展示文本)");
    }
  }

  class PanelView {
    constructor(prefs) {
      this.prefs = prefs;
      this.panelEl = null;
      this.fabEl = null;
      this.treeEl = null;
      this.statsEl = null;
      this.modalEl = null;
      this.modalBodyEl = null;
      this.modalTitleEl = null;
      this.widthRangeEl = null;
      this.widthValueEl = null;
      this.searchInputEl = null;
      this.dragHandle = null;
      this.resizeHandle = null;
      this.handlers = { refresh: () => {} };
      this.resizeListenerBound = false;
      this.treeHeightScheduled = false;
      this.hiddenState = !!this.prefs.get("hidden");
    }
    ensureMounted() {
      if (this.panelEl) return;
      this.ensureFab();
      this.createPanel();
    }
    ensureFab() {
      if (this.fabEl) return;
      const fab = document.createElement("div");
      fab.id = "gtt-fab";
      fab.innerHTML = `<span class="dot"></span><span class="txt">GPT Tree</span>`;
      fab.addEventListener("click", () => this.setHidden(false));
      document.body.appendChild(fab);
      this.fabEl = fab;
      this.syncFabVisibility();
    }
    createPanel() {
      if (this.panelEl) return;
      const panel = document.createElement("div");
      panel.id = "gtt-panel";
      panel.innerHTML = `
        <div id="gtt-resize" title="拖拽调整宽度"></div>
        <div id="gtt-header">
          <div class="title" id="gtt-drag">GPT Tree</div>
          <button class="btn" id="gtt-btn-refresh">刷新</button>
          <button class="btn" id="gtt-btn-hide" title="隐藏(Alt+T)">隐藏</button>
        </div>
        <div id="gtt-body">
          <input id="gtt-search" placeholder="搜索节点(文本/角色)… / 聚焦,Esc 清除">
          <div id="gtt-pref">
            <span style="opacity:.65" id="gtt-stats"></span>
            <div class="gtt-pref-row">
              <span class="gtt-pref-title">最大宽度</span>
              <input type="range" id="gtt-width-range" step="${CONFIG.PANEL_WIDTH_STEP}">
              <span class="gtt-pref-value" id="gtt-width-value"></span>
              <button type="button" class="gtt-pref-reset" id="gtt-width-reset" title="恢复默认宽度">重置</button>
            </div>
          </div>
          <div id="gtt-tree"></div>
        </div>
        <div id="gtt-modal">
          <div class="card">
            <div class="hd">
              <div style="font-weight:700;flex:1" id="gtt-md-title">节点预览</div>
              <button class="btn" id="gtt-md-close">关闭</button>
            </div>
            <div class="bd" id="gtt-md-body"></div>
          </div>
        </div>
      `;
      document.body.appendChild(panel);
      this.panelEl = panel;
      this.treeEl = DOMUtils.query("#gtt-tree", panel);
      this.statsEl = DOMUtils.query("#gtt-stats", panel);
      this.modalEl = DOMUtils.query("#gtt-modal", panel);
      this.modalBodyEl = DOMUtils.query("#gtt-md-body", panel);
      this.modalTitleEl = DOMUtils.query("#gtt-md-title", panel);
      this.widthRangeEl = DOMUtils.query("#gtt-width-range", panel);
      this.widthValueEl = DOMUtils.query("#gtt-width-value", panel);
      this.searchInputEl = DOMUtils.query("#gtt-search", panel);
      this.dragHandle = DOMUtils.query("#gtt-drag", panel);
      this.resizeHandle = DOMUtils.query("#gtt-resize", panel);
      const btnHide = DOMUtils.query("#gtt-btn-hide", panel);
      const btnRefresh = DOMUtils.query("#gtt-btn-refresh", panel);
      const btnCloseModal = DOMUtils.query("#gtt-md-close", panel);
      const widthResetBtn = DOMUtils.query("#gtt-width-reset", panel);
      if (btnHide) btnHide.addEventListener("click", () => this.setHidden(true));
      if (btnRefresh) btnRefresh.addEventListener("click", () => this.handlers.refresh());
      if (btnCloseModal) btnCloseModal.addEventListener("click", () => this.hideModal());
      if (widthResetBtn) widthResetBtn.addEventListener("click", () => this.resetWidth());
      if (this.searchInputEl) {
        const handleSearch = IdleScheduler.debounce((value) => {
          const query = (typeof value === "string" ? value : this.searchInputEl.value || "").trim().toLowerCase();
          DOMUtils.queryAll("#gtt-tree .gtt-node").forEach((node) => {
            node.style.display = node.textContent.toLowerCase().includes(query) ? "" : "none";
          });
          this.updateTreeHeight();
        }, 120);
        this.searchInputEl.addEventListener("input", (e) => handleSearch(e.target?.value));
      }
      if (this.widthRangeEl) {
        this.widthRangeEl.addEventListener("input", (e) => {
          const value = Number(e.target?.value);
          if (Number.isFinite(value)) this.syncWidth(value, { preview: true });
        });
        this.widthRangeEl.addEventListener("change", (e) => {
          const value = Number(e.target?.value);
          this.setWidth(Number.isFinite(value) ? value : null);
        });
      }
      this.enableDrag();
      this.enableResize();
      this.applyState();
    }
    registerHandlers(handlers) {
      this.handlers = { ...this.handlers, ...handlers };
    }
    panelExists() { return !!this.panelEl; }
    get treeContainer() { return this.treeEl; }
    focusSearch() { this.searchInputEl?.focus(); }
    clearSearch() {
      if (!this.searchInputEl) return;
      this.searchInputEl.value = "";
      this.searchInputEl.dispatchEvent(new Event("input"));
    }
    showModal(text, reason = "节点预览") {
      if (!this.modalEl || !this.modalBodyEl || !this.modalTitleEl) return;
      this.modalBodyEl.innerHTML = MarkdownRenderer.renderLite(text);
      this.modalTitleEl.textContent = reason;
      this.modalEl.style.display = "flex";
    }
    hideModal() {
      if (!this.modalEl || !this.modalBodyEl) return;
      this.modalEl.style.display = "none";
      this.modalBodyEl.innerHTML = "";
    }
    isModalOpen() {
      return this.modalEl?.style?.display === "flex";
    }
    updateStats(total) {
      if (this.statsEl) this.statsEl.textContent = total ? `节点:${total}` : "";
    }
    toggleHidden() {
      this.setHidden(!this.hiddenState);
    }
    setHidden(value, { silent = false } = {}) {
      this.hiddenState = !!value;
      if (this.panelEl) {
        this.panelEl.style.display = this.hiddenState ? "none" : "flex";
        if (!this.hiddenState) this.updateTreeHeight();
      }
      this.syncFabVisibility();
      this.prefs.set("hidden", this.hiddenState, { silent });
    }
    syncFabVisibility() {
      if (!this.fabEl) return;
      this.fabEl.style.display = this.hiddenState ? "inline-flex" : "none";
    }
    applyState() {
      this.setHidden(this.prefs.get("hidden"), { silent: true });
      this.applyPosition();
      this.syncWidth();
      this.ensureResizeListener();
    }
    applyPosition(panel = this.panelEl) {
      if (!panel) return;
      const pos = this.prefs.get("pos");
      if (pos) {
        panel.style.left = `${pos.left}px`;
        panel.style.top = `${pos.top}px`;
        panel.style.right = "auto";
      }
    }
    rememberPosition() {
      if (!this.panelEl) return;
      const rect = this.panelEl.getBoundingClientRect();
      this.prefs.set("pos", { left: Math.round(rect.left), top: Math.round(rect.top) });
    }
    enableDrag() {
      const panel = this.panelEl;
      const handle = this.dragHandle;
      if (!panel || !handle) return;
      let dragging = false;
      let startX = 0;
      let startY = 0;
      let startLeft = 0;
      let startTop = 0;
      handle.addEventListener("mousedown", (e) => {
        dragging = true;
        startX = e.clientX;
        startY = e.clientY;
        const rect = panel.getBoundingClientRect();
        startLeft = rect.left;
        startTop = rect.top;
        panel.style.right = "auto";
        const onMove = (ev) => {
          if (!dragging) return;
          const left = startLeft + (ev.clientX - startX);
          const top = startTop + (ev.clientY - startY);
          panel.style.left = `${Math.max(8, left)}px`;
          panel.style.top = `${Math.max(8, top)}px`;
        };
        const onUp = () => {
          dragging = false;
          document.removeEventListener("mousemove", onMove);
          this.rememberPosition();
        };
        document.addEventListener("mousemove", onMove);
        document.addEventListener("mouseup", onUp, { once: true });
      });
    }
    enableResize() {
      const panel = this.panelEl;
      const handle = this.resizeHandle;
      if (!panel || !handle) return;
      let resizing = false;
      let startX = 0;
      let startWidth = 0;
      let previewWidth = null;
      const cleanup = (onMove, onUp) => {
        document.removeEventListener("mousemove", onMove);
        document.removeEventListener("mouseup", onUp);
        document.removeEventListener("touchmove", onMove, true);
        document.removeEventListener("touchend", onUp, true);
        document.removeEventListener("touchcancel", onUp, true);
      };
      const startResize = (clientX) => {
        resizing = true;
        startX = clientX;
        startWidth = panel.getBoundingClientRect().width;
        previewWidth = startWidth;
        const prevUserSelect = document.body.style.userSelect;
        const prevCursor = document.body.style.cursor;
        document.body.style.userSelect = "none";
        document.body.style.cursor = "ew-resize";
        const handleMove = (evt) => {
          if (!resizing) return;
          if (evt?.cancelable) evt.preventDefault();
          const point = evt.touches ? evt.touches[0] : evt;
          if (!point) return;
          const delta = startX - point.clientX;
          const next = this.clampWidth(startWidth + delta);
          previewWidth = next;
          this.syncWidth(next, { preview: true });
        };
        const handleUp = () => {
          if (!resizing) return;
          resizing = false;
          cleanup(handleMove, handleUp);
          document.body.style.userSelect = prevUserSelect;
          document.body.style.cursor = prevCursor;
          const stored = this.prefs.get("width");
          if (previewWidth != null && Math.abs(previewWidth - startWidth) >= 1) {
            this.setWidth(previewWidth);
          } else if (!Number.isFinite(stored)) {
            this.syncWidth(null);
          } else {
            this.syncWidth(stored);
          }
        };
        document.addEventListener("mousemove", handleMove);
        document.addEventListener("mouseup", handleUp, { once: true });
        document.addEventListener("touchmove", handleMove, { capture: true, passive: false });
        document.addEventListener("touchend", handleUp, { once: true, capture: true });
        document.addEventListener("touchcancel", handleUp, { once: true, capture: true });
      };
      handle.addEventListener("mousedown", (e) => {
        e.preventDefault();
        startResize(e.clientX);
      });
      handle.addEventListener("touchstart", (e) => {
        const touch = e.touches?.[0];
        if (!touch) return;
        e.preventDefault();
        startResize(touch.clientX);
      }, { passive: false });
      handle.addEventListener("dblclick", (e) => {
        e.preventDefault();
        this.resetWidth();
      });
    }
    clampWidth(value) {
      const max = this.getViewportWidthLimit();
      if (!Number.isFinite(value)) return max;
      return Math.min(Math.max(CONFIG.PANEL_WIDTH_MIN, Math.round(value)), max);
    }
    getViewportWidthLimit() {
      const viewportLimit = Math.max(CONFIG.PANEL_WIDTH_MIN, Math.floor(window.innerWidth - 24));
      return Math.min(CONFIG.PANEL_WIDTH_MAX, viewportLimit);
    }
    getAutoWidth() {
      return this.clampWidth(CONFIG.PANEL_WIDTH_DEFAULT);
    }
    syncWidth(value = this.prefs.get("width"), { preview = false } = {}) {
      if (!this.panelEl) return null;
      this.updateWidthRangeBounds();
      let applied = null;
      if (Number.isFinite(value)) {
        const clamped = this.clampWidth(value);
        this.panelEl.style.setProperty("--gtt-panel-width", `${clamped}px`);
        this.updateWidthDisplay(clamped);
        applied = clamped;
      } else {
        this.panelEl.style.removeProperty("--gtt-panel-width");
        this.updateWidthDisplay(null);
      }
      if (!preview) this.updateTreeHeight();
      return applied;
    }
    setWidth(value, { silent = false } = {}) {
      if (!Number.isFinite(value)) {
        this.prefs.set("width", null, { silent });
        this.syncWidth(null);
        return null;
      }
      const clamped = this.clampWidth(value);
      this.prefs.set("width", clamped, { silent });
      this.syncWidth(clamped);
      return clamped;
    }
    resetWidth() {
      this.setWidth(null);
    }
    updateWidthRangeBounds() {
      if (!this.widthRangeEl) return;
      this.widthRangeEl.min = String(CONFIG.PANEL_WIDTH_MIN);
      this.widthRangeEl.max = String(this.getViewportWidthLimit());
    }
    updateWidthDisplay(value) {
      if (this.widthValueEl) {
        this.widthValueEl.textContent = Number.isFinite(value) ? `${this.clampWidth(value)}px` : "自动";
      }
      if (this.widthRangeEl) {
        const fallback = this.getAutoWidth();
        const displayValue = Number.isFinite(value) ? this.clampWidth(value) : fallback;
        this.widthRangeEl.value = String(displayValue);
      }
    }
    ensureResizeListener() {
      if (this.resizeListenerBound) return;
      this.resizeListenerBound = true;
      window.addEventListener("resize", () => {
        this.syncWidth();
        this.updateTreeHeight();
      });
    }
    updateTreeHeight(immediate = false) {
      if (!this.panelEl) return;
      const measure = () => {
        if (!this.treeEl) return;
        const nodes = Array.from(this.treeEl.querySelectorAll(".gtt-node")).filter((node) => node.offsetParent);
        if (!nodes.length) {
          this.treeEl.style.removeProperty("--gtt-tree-max-height");
          return;
        }
        const take = nodes.slice(0, Math.min(3, nodes.length));
        let total = 0;
        for (const node of take) {
          const style = window.getComputedStyle(node);
          const marginTop = parseFloat(style.marginTop) || 0;
          const marginBottom = parseFloat(style.marginBottom) || 0;
          total += node.offsetHeight + marginTop + marginBottom;
        }
        const treeStyle = window.getComputedStyle(this.treeEl);
        const paddingTop = parseFloat(treeStyle.paddingTop) || 0;
        const paddingBottom = parseFloat(treeStyle.paddingBottom) || 0;
        const height = Math.max(0, Math.ceil(total + paddingTop + paddingBottom));
        if (height > 0) {
          this.treeEl.style.setProperty("--gtt-tree-max-height", `${height}px`);
        } else {
          this.treeEl.style.removeProperty("--gtt-tree-max-height");
        }
      };
      if (immediate) {
        measure();
        return;
      }
      if (this.treeHeightScheduled) return;
      this.treeHeightScheduled = true;
      const runner = () => {
        this.treeHeightScheduled = false;
        measure();
      };
      if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
        window.requestAnimationFrame(runner);
      } else {
        setTimeout(runner, 16);
      }
    }
  }

  class TreeBuilder {
    static isToolishRole(role) {
      return role === "tool" || role === "system" || role === "function";
    }
    static getRecText(rec) {
      const parts = rec?.message?.content?.parts ?? [];
      if (Array.isArray(parts)) return parts.join("\n");
      if (typeof parts === "string") return parts;
      return "";
    }
    static isVisibleRec(rec) {
      if (!rec) return false;
      const role = rec?.message?.author?.role || "assistant";
      if (TreeBuilder.isToolishRole(role)) return false;
      const text = TreeBuilder.getRecText(rec);
      return !!TextUtils.normalize(text);
    }
    static visibleParentId(mapping, id) {
      let cur = id;
      let guard = 0;
      while (guard++ < 4096) {
        const parentId = mapping[cur]?.parent;
        if (parentId == null) return null;
        const parentRec = mapping[parentId];
        if (TreeBuilder.isVisibleRec(parentRec)) return parentId;
        cur = parentId;
      }
      return null;
    }
    static dedupBySig(ids, mapping) {
      const seen = new Set();
      const out = [];
      for (const cid of ids) {
        const rec = mapping[cid];
        if (!rec) continue;
        const role = rec?.message?.author?.role || "assistant";
        const text = TextUtils.normalize(TreeBuilder.getRecText(rec));
        const sig = Signature.create(role, text);
        if (!seen.has(sig)) {
          seen.add(sig);
          out.push(cid);
        }
      }
      return out;
    }
    static foldSameRoleChain(startId, mapping, childrenMap) {
      let cur = startId;
      let rec = mapping[cur];
      const role = rec?.message?.author?.role || "assistant";
      let text = TreeBuilder.getRecText(rec);
      let guard = 0;
      const chainIds = [];
      const chainSigs = [];
      while (rec && guard++ < 4096) {
        const curText = TreeBuilder.getRecText(rec);
        if (curText) {
          chainIds.push(cur);
          chainSigs.push(Signature.create(role, curText));
        }
        const kids = childrenMap.get(cur) || [];
        if (kids.length !== 1) break;
        const kidId = kids[0];
        const kidRec = mapping[kidId];
        const kidRole = kidRec?.message?.author?.role || "assistant";
        const kidText = TreeBuilder.getRecText(kidRec);
        if (kidRole === role && kidText && text) {
          text = `${text}\n${kidText}`.trim();
          cur = kidId;
          rec = kidRec;
          continue;
        }
        break;
      }
      return { id: cur, role, text, chainIds, chainSigs };
    }
    static fromMapping(mapping) {
      const visibleIds = Object.keys(mapping).filter((id) => TreeBuilder.isVisibleRec(mapping[id]));
      const parentMap = new Map();
      for (const vid of visibleIds) {
        parentMap.set(vid, TreeBuilder.visibleParentId(mapping, vid));
      }
      const childrenMap = new Map(visibleIds.map((id) => [id, []]));
      for (const vid of visibleIds) {
        const parentId = parentMap.get(vid);
        if (parentId && childrenMap.has(parentId)) {
          childrenMap.get(parentId).push(vid);
        }
      }
      for (const [pid, arr] of childrenMap.entries()) {
        childrenMap.set(pid, TreeBuilder.dedupBySig(arr, mapping));
      }
      const roots = visibleIds.filter((id) => parentMap.get(id) == null);
      const toNode = (id) => {
        const folded = TreeBuilder.foldSameRoleChain(id, mapping, childrenMap);
        const currentId = folded.id;
        const currentRole = folded.role;
        const currentText = folded.text;
        const sig = Signature.create(currentRole, currentText);
        const chainIds = folded.chainIds?.length ? folded.chainIds : [currentId];
        const chainSigs = folded.chainSigs?.length ? folded.chainSigs : [sig];
        const children = (childrenMap.get(currentId) || []).map(toNode).filter(Boolean);
        return { id: currentId, role: currentRole, text: currentText, sig, chainIds, chainSigs, children };
      };
      return roots.map(toNode).filter(Boolean);
    }
    static fromLinear(linear) {
      const nodes = [];
      for (let i = 0; i < linear.length; i++) {
        const current = linear[i];
        if (current.role === "user") {
          const next = linear[i + 1];
          const pair = { id: current.id, role: "user", text: current.text, sig: current.sig, children: [] };
          if (next && next.role === "assistant") {
            pair.children.push({ id: next.id, role: "assistant", text: next.text, sig: next.sig, children: [] });
          }
          nodes.push(pair);
        } else {
          nodes.push({ id: current.id, role: "assistant", text: current.text, sig: current.sig, children: [] });
        }
      }
      return nodes;
    }
  }

  class TreeRenderer {
    constructor(panel, branchState, navigator) {
      this.panel = panel;
      this.branchState = branchState;
      this.navigator = navigator;
    }
    render(treeData) {
      const targetEl = this.panel.treeContainer;
      if (!targetEl) return;
      targetEl.innerHTML = "";
      this.panel.updateTreeHeight(true);
      const stats = { total: 0 };
      const container = document.createDocumentFragment();
      const queue = [];
      const pushList = (nodes, parent) => { for (const node of nodes) queue.push({ node, parent }); };
      const createItem = (node) => {
        const item = document.createElement("div");
        item.className = "gtt-node";
        item.dataset.nodeId = node.id;
        item.dataset.sig = node.sig;
        item.title = `${node.id}\n\n${node.text || ""}`;
        if (node.chainIds) item._chainIds = node.chainIds;
        if (node.chainSigs) item._chainSigs = node.chainSigs;
        const head = document.createElement("div");
        head.className = "head";
        const badge = document.createElement("span");
        badge.className = "badge";
        badge.textContent = node.role === "user" ? "U" : (node.role === "assistant" ? "A" : (node.role || "·"));
        const title = document.createElement("span");
        title.className = "title";
        title.textContent = node.role === "user" ? "用户" : "Asst";
        const meta = document.createElement("span");
        meta.className = "meta";
        meta.textContent = node.children?.length ? `×${node.children.length}` : "";
        const pv = document.createElement("span");
        pv.className = "pv";
        const pvLines = PreviewBuilder.lines(node.text);
        pvLines.forEach((line, idx) => {
          const lineEl = document.createElement("span");
          lineEl.className = "pv-line";
          if (idx === 2) lineEl.classList.add("pv-line-more");
          lineEl.textContent = line;
          pv.appendChild(lineEl);
        });
        head.append(badge, title);
        if (meta.textContent) head.append(meta);
        item.append(head, pv);
        item.addEventListener("click", () => this.navigator.jumpTo(node));
        return item;
      };
      const rootDiv = document.createElement("div");
      container.appendChild(rootDiv);
      pushList(treeData, rootDiv);
      const step = () => {
        let count = 0;
        while (count < CONFIG.RENDER_CHUNK && queue.length) {
          const { node, parent } = queue.shift();
          const item = createItem(node);
          parent.appendChild(item);
          stats.total++;
          if (node.children?.length) {
            const kids = document.createElement("div");
            kids.className = "gtt-children";
            parent.appendChild(kids);
            pushList(node.children, kids);
          }
          count++;
        }
        if (queue.length) {
          IdleScheduler.rafIdle(step);
        } else {
          targetEl.appendChild(container);
          this.panel.updateStats(stats.total);
          this.applyHighlight();
          this.panel.updateTreeHeight();
        }
      };
      step();
    }
    applyHighlight() {
      const targetEl = this.panel.treeContainer;
      if (!targetEl) return;
      this.branchState.applyHighlight(targetEl);
    }
  }

  class BackendGateway {
    constructor() {
      this.origFetch = window.fetch.bind(window);
      this.lastAuth = null;
      this.patched = false;
    }
    extractAuthHeaders(input, init) {
      try {
        if (input instanceof Request) {
          const headers = Object.fromEntries(input.headers.entries());
          return headers.authorization || headers.Authorization || null;
        }
        const headers = init?.headers;
        if (headers instanceof Headers) {
          return headers.get("authorization") || headers.get("Authorization");
        }
        if (headers && typeof headers === "object") {
          return headers.authorization || headers.Authorization || null;
        }
      } catch (_) {
        return null;
      }
      return null;
    }
    rememberAuth(authHeader) {
      if (!authHeader || this.lastAuth) return;
      this.lastAuth = { Authorization: authHeader };
    }
    async ensureAuth() {
      if (this.lastAuth?.Authorization) return this.lastAuth;
      try {
        const res = await this.origFetch("/api/auth/session", { credentials: "include" });
        if (res.ok) {
          const data = await res.json();
          if (data?.accessToken) {
            this.lastAuth = { Authorization: `Bearer ${data.accessToken}` };
            return this.lastAuth;
          }
        }
      } catch (_) {
        /* ignore */
      }
      return this.lastAuth || {};
    }
    withHeaders(extra = {}) {
      return { ...(this.lastAuth || {}), ...extra };
    }
    patchFetch(onMapping) {
      if (this.patched) return;
      this.patched = true;
      window.fetch = async (...args) => {
        const [input, init] = args;
        const authHeader = this.extractAuthHeaders(input, init);
        if (authHeader) this.rememberAuth(authHeader);
        const response = await this.origFetch(...args);
        try {
          const url = typeof input === "string" ? input : (input?.url || "");
          if (/\/backend-api\/conversation\//.test(url)) {
            const clone = response.clone();
            const json = await clone.json();
            if (json?.mapping) {
              onMapping(json.mapping);
            }
          }
        } catch (_) {
          /* ignore */
        }
        return response;
      };
    }
    async fetchMapping(conversationId) {
      if (!conversationId) return null;
      await this.ensureAuth();
      const { get: urls } = CONFIG.ENDPOINTS(conversationId);
      for (const url of urls) {
        try {
          const response = await this.origFetch(url, { credentials: "include", headers: this.withHeaders() });
          if (response.ok) {
            const json = await response.json();
            if (json?.mapping) return json.mapping;
          }
        } catch (_) {
          /* ignore */
        }
      }
      return null;
    }
  }

  class DOMWatcher {
    constructor(onChange) {
      this.observer = new MutationObserver(IdleScheduler.debounce(onChange, CONFIG.OBS_DEBOUNCE_MS));
    }
    start() {
      this.observer.observe(document.body, { childList: true, subtree: true });
    }
    stop() {
      this.observer.disconnect();
    }
  }

  class RouterWatcher {
    constructor(onChange) {
      this.onChange = onChange;
      this.bound = false;
    }
    start() {
      if (this.bound) return;
      this.bound = true;
      const origPush = history.pushState;
      const origReplace = history.replaceState;
      const fire = () => window.dispatchEvent(new Event("gtt:locationchange"));
      history.pushState = function (...args) {
        const result = origPush.apply(this, args);
        fire();
        return result;
      };
      history.replaceState = function (...args) {
        const result = origReplace.apply(this, args);
        fire();
        return result;
      };
      window.addEventListener("popstate", fire);
      window.addEventListener("gtt:locationchange", this.onChange);
      window.addEventListener("popstate", this.onChange);
    }
  }

  class KeyboardController {
    constructor(panel, navigator) {
      this.panel = panel;
      this.navigator = navigator;
    }
    bind() {
      document.addEventListener("keydown", (e) => {
        const searchInput = this.panel.searchInputEl;
        if (e.key === "/" && !e.metaKey && !e.ctrlKey) {
          e.preventDefault();
          this.panel.focusSearch();
        }
        if (e.key === "Escape") {
          if (this.panel.isModalOpen()) {
            this.navigator.closeModal();
          } else if (searchInput) {
            this.panel.clearSearch();
          }
        }
        if (!e.altKey) return;
        if (e.key === "t" || e.key === "T") {
          e.preventDefault();
          this.panel.toggleHidden();
        }
      });
    }
  }

  class LocationHelper {
    static getConversationId() {
      const match = location.pathname.match(/\/c\/([a-z0-9-]{10,})/i) || [];
      return match[1] || null;
    }
  }

  class GPTBranchTreeNavigatorApp {
    constructor() {
      this.prefs = new PreferenceStore(CONFIG.LS_KEY, { hidden: false, pos: null, width: null });
      this.branchState = new BranchState();
      this.panel = new PanelView(this.prefs);
      this.navigator = new Navigator(this.branchState, this.panel);
      this.renderer = new TreeRenderer(this.panel, this.branchState, this.navigator);
      this.gateway = new BackendGateway();
      this.domWatcher = new DOMWatcher(() => {
        this.branchState.collectFromDom();
        this.renderer.applyHighlight();
      });
      this.routerWatcher = new RouterWatcher(() => {
        this.rebuild({ forceFetch: true, hard: true });
      });
      this.keyboard = new KeyboardController(this.panel, this.navigator);
      this.panel.registerHandlers({ refresh: () => this.rebuild({ forceFetch: true }) });
    }
    async rebuild(opts = {}) {
      this.panel.ensureMounted();
      if (opts.hard) this.branchState.setMapping(null);
      const linearNodes = this.branchState.collectFromDom();
      if (opts.forceFetch || !this.branchState.mapping) {
        const mapping = await this.gateway.fetchMapping(LocationHelper.getConversationId());
        if (mapping) {
          this.applyMapping(mapping);
          return;
        }
      }
      if (this.branchState.mapping) {
        this.renderer.render(TreeBuilder.fromMapping(this.branchState.mapping));
      } else {
        this.renderer.render(TreeBuilder.fromLinear(linearNodes));
      }
    }
    applyMapping(mapping) {
      if (!mapping) return;
      this.branchState.setMapping(mapping);
      this.panel.ensureMounted();
      this.renderer.render(TreeBuilder.fromMapping(mapping));
    }
    boot() {
      this.panel.ensureMounted();
      this.domWatcher.start();
      this.routerWatcher.start();
      this.keyboard.bind();
      this.branchState.collectFromDom();
      this.rebuild();
    }
  }

  const app = new GPTBranchTreeNavigatorApp();
  const gateway = app.gateway;
  gateway.patchFetch((mapping) => app.applyMapping(mapping));
  const readyTimer = setInterval(() => {
    if (document.querySelector("main")) {
      clearInterval(readyTimer);
      app.boot();
    }
  }, 300);

})();