GPT Optimum

Improves ChatGPT performance by virtualizing off-screen messages.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         GPT Optimum
// @namespace    https://github.com/YashRana738/GptOptimum
// @version      2.1
// @description  Improves ChatGPT performance by virtualizing off-screen messages.
// @author       Yash Rana
// @license      MIT
// @homepageURL  https://github.com/YashRana738/GptOptimum
// @supportURL   https://github.com/YashRana738/GptOptimum/issues
// @match        *://chatgpt.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  // ── Helpers ──────────────────────────────────────────────────
  const $ = (s, root = document) => root.querySelector(s);
  const $$ = (s, root = document) => root.querySelectorAll(s);
  const isChatRoute = () => location.pathname.startsWith('/c/');
  const getChatId = () => {
    const m = location.pathname.match(/^\/c\/([a-f0-9-]+)/);
    return m ? m[1] : null;
  };

  // ── Settings (GM storage) ───────────────────────────────────
  const DEFAULTS = { enabled: true, debug: false, aggressive: false, instantNewChat: true };

  const cfg = {
    get(k) { return GM_getValue(k, DEFAULTS[k]); },
    set(k, v) { GM_setValue(k, v); },
    all() { return { enabled: this.get('enabled'), debug: this.get('debug'), aggressive: this.get('aggressive'), instantNewChat: this.get('instantNewChat') }; },
  };

  // ── Early UI lock (document-start) ──────────────────────────
  if (cfg.get('enabled') && isChatRoute()) {
    const bootId = getChatId();
    const storedBootId = cfg.get('lastChatId');
    const isNewChat = bootId && storedBootId && storedBootId < bootId && cfg.get('instantNewChat');
    if (!isNewChat) {
      document.documentElement.classList.add('cgv-loading');
    }
    if (bootId && (!storedBootId || bootId > storedBootId)) cfg.set('lastChatId', bootId);
  }

  // ── CSS ─────────────────────────────────────────────────────
  GM_addStyle(`
    /* === Theme tokens === */
    :root{--cgv-bg:#fff;--cgv-text:#0d0d0d;--cgv-muted:#666;--cgv-border:#e5e5e5;--cgv-shadow:0 4px 6px -1px rgb(0 0 0/.1);--cgv-primary:#10a37f}
    html.dark,:root{--cgv-bg:#fff;--cgv-text:#0d0d0d;--cgv-muted:#666;--cgv-border:#e5e5e5}
    html.dark{--cgv-bg:#2f2f2f;--cgv-text:#ececec;--cgv-muted:#b4b4b4;--cgv-border:#424242;--cgv-shadow:0 4px 6px -1px rgb(0 0 0/.5)}
    @media(prefers-color-scheme:dark){html:not(.light){--cgv-bg:#2f2f2f;--cgv-text:#ececec;--cgv-muted:#b4b4b4;--cgv-border:#424242;--cgv-shadow:0 4px 6px -1px rgb(0 0 0/.5)}}

    /* === Loading lock === */
    html.cgv-loading body{pointer-events:none!important;user-select:none!important;cursor:wait!important}

    /* === Virtualization === */
    .cgv-off{content-visibility:hidden}
    .cgv-msg{transition:opacity .1s ease-in-out}

    /* === Debug overlay === */
    body.cgv-debug .cgv-off{background:rgba(255,0,0,.1)!important;border:1px dashed red!important;position:relative}
    body.cgv-debug .cgv-off::after{content:"Unloaded";position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:red;font-weight:700;font-size:14px;pointer-events:none}

    /* === Wrapper (holds pill + popup) === */
    .cgv-wrap{position:fixed;bottom:24px;right:24px;z-index:999999;display:flex;flex-direction:column;align-items:flex-end;pointer-events:auto}

    /* === Indicator pill === */
    .cgv-pill{
      display:flex;align-items:center;
      background:var(--cgv-bg);color:var(--cgv-text);
      border:1px solid var(--cgv-border);box-shadow:var(--cgv-shadow);
      padding:8px 14px;border-radius:20px;
      font:500 13px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
      cursor:pointer;white-space:nowrap;box-sizing:border-box;
      opacity:0;transform:translateY(10px);
      gap:8px;max-width:220px;
      transition:opacity .25s ease,
        transform .4s cubic-bezier(.34,1.56,.64,1),
        max-width .4s cubic-bezier(.4,0,.2,1),
        padding .4s cubic-bezier(.4,0,.2,1),
        height .4s cubic-bezier(.4,0,.2,1),
        border-radius .4s cubic-bezier(.4,0,.2,1),
        border-color .25s,
        gap .4s cubic-bezier(.4,0,.2,1);
      height:34px;overflow:hidden;
    }
    .cgv-pill.on{opacity:1;transform:translateY(0)}

    /* Collapsed — max-width 220→30 is fully animatable, padding fills the circle */
    .cgv-pill.mini{
      max-width:30px;
      padding:10px;
      border-radius:50%;
      gap:0;
      height:30px;
    }
    .cgv-pill.mini .cgv-dot{margin:0}

    /* Expand pill on hover even when collapsed */
    .cgv-wrap:hover .cgv-pill.mini{max-width:220px;padding:8px 14px;border-radius:20px;height:34px;gap:8px}
    .cgv-wrap:hover .cgv-pill.mini .cgv-label{max-width:150px}

    /* Dot */
    .cgv-dot{
      width:8px;height:8px;min-width:8px;min-height:8px;
      border-radius:50%;flex-shrink:0;position:relative;z-index:2;
      background:var(--cgv-primary);box-shadow:0 0 6px var(--cgv-primary);
      animation:cgv-pulse 2s infinite;
      transition:background .25s,box-shadow .25s,border .25s;
    }
    /* Label — max-width clips text smoothly, NO opacity change so it slides not fades */
    .cgv-label{
      display:inline-block;overflow:hidden;
      max-width:150px;
      transition:max-width .4s cubic-bezier(.4,0,.2,1);
    }
    .cgv-pill.mini .cgv-label{max-width:0}

    /* States */
    .cgv-pill.busy{border-color:var(--cgv-primary);opacity:1!important}
    .cgv-pill.busy .cgv-dot{animation:cgv-spin .8s linear infinite;background:0 0;border:2px solid var(--cgv-primary);border-top-color:transparent;width:10px;height:10px;box-shadow:none}

    /* Disabled state — red dot */
    .cgv-pill.dead{border-color:#ef4444}
    .cgv-pill.dead .cgv-dot{background:#ef4444;box-shadow:0 0 6px #ef4444;animation:none}

    @keyframes cgv-spin{to{transform:rotate(360deg)}}
    @keyframes cgv-pulse{0%,100%{opacity:.5}50%{opacity:1}}

    /* === Blocker banner === */
    .cgv-block{
      position:fixed;bottom:24px;right:24px;
      background:#ef4444;color:#fff;padding:10px 18px;border-radius:25px;
      font:600 13px -apple-system,sans-serif;
      box-shadow:0 10px 25px -5px rgba(239,68,68,.4);
      z-index:1000000;display:flex;align-items:center;gap:8px;
      transform:translateY(120px);opacity:0;
      transition:transform .6s cubic-bezier(.68,-.6,.32,1.6),opacity .35s;
    }
    .cgv-block.on{transform:translateY(0);opacity:1}
    .cgv-block svg{width:16px;height:16px;stroke:currentColor;stroke-width:2.5;fill:none}
    .cgv-pill.pushed{transform:translateY(-64px)!important}

    /* === Popup panel === */
    .cgv-panel{
      position:absolute;bottom:calc(100% + 14px);right:0;width:290px;
      background:var(--cgv-bg);color:var(--cgv-text);
      border:1px solid var(--cgv-border);border-radius:18px;
      box-shadow:0 8px 28px rgba(0,0,0,.16);
      font-family:'Inter',-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
      opacity:0;transform:translateY(6px) scale(.97);pointer-events:none;
      transition:opacity .18s,transform .22s cubic-bezier(.34,1.56,.64,1);
      z-index:1000001;overflow:visible;
    }
    /* Hover bridge */
    .cgv-panel::after{content:"";position:absolute;top:100%;left:0;right:0;height:22px}
    .cgv-wrap:hover .cgv-panel{opacity:1;transform:translateY(0) scale(1);pointer-events:auto}

    .cgv-ph{padding:18px 16px 8px;text-align:center}
    .cgv-ph h3{margin:0;font-size:15px;font-weight:600;display:flex;align-items:center;justify-content:center;gap:8px}
    .cgv-ph p{margin:3px 0 0;font-size:11px;color:var(--cgv-muted)}
    .cgv-ph .cgv-hd{width:9px;height:9px;background:var(--cgv-primary);border-radius:50%;box-shadow:0 0 6px var(--cgv-primary);flex-shrink:0}

    .cgv-pc{padding:8px 16px 16px}

    /* Loading overlay inside panel */
    .cgv-loading-msg{
      display:none;padding:28px 16px;text-align:center;
      font-size:13px;font-weight:500;color:var(--cgv-muted);
    }
    .cgv-loading-msg .cgv-lspin{
      display:inline-block;width:18px;height:18px;border:2px solid var(--cgv-border);
      border-top-color:var(--cgv-primary);border-radius:50%;
      animation:cgv-spin .7s linear infinite;margin-bottom:10px;
    }
    .cgv-panel.loading .cgv-pc{display:none}
    .cgv-panel.loading .cgv-loading-msg{display:block}

    /* Card */
    .cgv-card{
      background:var(--cgv-bg);border:1px solid var(--cgv-border);border-radius:14px;
      padding:12px 14px;margin-bottom:8px;
      display:flex;align-items:center;justify-content:space-between;
      box-shadow:0 1px 3px rgba(0,0,0,.05);cursor:pointer;
      transition:border-color .2s,transform .15s;
    }
    .cgv-card:last-child{margin-bottom:0}
    .cgv-card:hover{border-color:var(--cgv-primary);transform:translateY(-1px)}
    .cgv-card.off{opacity:.45;pointer-events:none;filter:grayscale(.5)}
    .cgv-card.main{border-color:var(--cgv-primary);margin-bottom:14px}
    .cgv-ci{display:flex;flex-direction:column;gap:1px}
    .cgv-cl{font-size:13px;font-weight:500}
    .cgv-cd{font-size:10.5px;color:var(--cgv-muted)}

    /* Toggle switch */
    .cgv-sw{position:relative;display:inline-block;width:34px;height:19px;flex-shrink:0}
    .cgv-sw input{opacity:0;width:0;height:0}
    .cgv-sl{
      position:absolute;cursor:pointer;inset:0;
      background:var(--cgv-border);border-radius:19px;
      transition:.25s cubic-bezier(.4,0,.2,1);
    }
    .cgv-sl::before{
      content:"";position:absolute;width:13px;height:13px;left:3px;bottom:3px;
      background:#fff;border-radius:50%;box-shadow:0 1px 3px rgba(0,0,0,.2);
      transition:.25s cubic-bezier(.4,0,.2,1);
    }
    .cgv-sw input:checked+.cgv-sl{background:var(--cgv-primary)}
    .cgv-sw input:checked+.cgv-sl::before{transform:translateX(15px)}

    .cgv-ft{padding:0 16px 12px;text-align:center;font-size:10px;color:var(--cgv-muted);opacity:.5}
  `);

  // ── Virtualizer ─────────────────────────────────────────────
  class Virtualizer {
    constructor() {
      this.enabled = false;
      this.aggressive = false;
      this.io = null;
    }

    boot(enabled, aggressive) {
      this.enabled = enabled;
      this.aggressive = aggressive;
      if (enabled) this._createObserver();
    }

    _margin() { return this.aggressive ? '5% 0px' : '100% 0px'; }

    _createObserver() {
      this.io?.disconnect();
      this.io = new IntersectionObserver(entries => {
        if (!this.enabled) return;
        for (const e of entries) {
          if (e.isIntersecting) this._restore(e.target);
          else this._unload(e.target);
        }
      }, { rootMargin: this._margin(), threshold: 0 });
      $$('.cgv-msg').forEach(el => this.io.observe(el));
    }

    setEnabled(v) {
      this.enabled = v;
      if (v) this._createObserver();
      else { this.io?.disconnect(); $$('.cgv-off').forEach(el => this._restore(el)); }
    }

    setAggressive(v) {
      const changed = this.aggressive !== v;
      this.aggressive = v;
      if (changed && this.enabled) this._createObserver();
    }

    setDebug(v) {
      document.body?.classList.toggle('cgv-debug', v);
    }

    observe(el) { this.io?.observe(el); }

    _unload(el) {
      if (el.classList.contains('cgv-off')) return;
      const h = Math.round(el.getBoundingClientRect().height);
      if (h < 60) return;
      el.style.containIntrinsicSize = `auto ${h}px`;
      el.classList.add('cgv-off');
    }
    _restore(el) {
      if (!el.classList.contains('cgv-off')) return;
      el.classList.remove('cgv-off');
      el.style.containIntrinsicSize = '';
    }
  }

  const virt = new Virtualizer();

  // ── Selectors ───────────────────────────────────────────────
  const MSG_SEL = '[data-message-author-role],article,[data-testid^="conversation-turn-"]';

  // ── App ─────────────────────────────────────────────────────
  class App {
    constructor() {
      this.tracked = new Set();
      this.pill = null;
      this.wrap = null;
      this.blocker = null;
      this.panel = null;
      this.busy = isChatRoute();
      this.lastUrl = location.href;
      this.collapseTimer = null;
      this.statusLock = false;
    }

    async run() {
      await this._waitBody();
      const s = cfg.all();

      virt.boot(s.enabled, s.aggressive);
      virt.setDebug(s.debug);

      this._buildUI(s);
      this._observe();
      this._watchUrl();

      if (s.enabled && this.busy) this._optimise();
      else { this.busy = false; this._unlock(); this._sync(s.enabled); }
    }

    // ── Input blocker during optimisation ──────────────────────
    _blockInputs() {
      const handler = e => {
        if (!this.busy || !virt.enabled) return;
        // Never block events inside our UI wrapper
        if (this.wrap?.contains(e.target)) return;
        e.stopImmediatePropagation();
        e.preventDefault();
      };
      for (const t of ['keydown', 'keyup', 'keypress', 'contextmenu', 'mousedown', 'click', 'dblclick', 'auxclick'])
        window.addEventListener(t, handler, true);
    }

    // ── URL change detection (SPA) ────────────────────────────
    _watchUrl() {
      setInterval(() => {
        if (location.href === this.lastUrl) return;
        const prevUrl = this.lastUrl;
        this.lastUrl = location.href;
        // Was the previous page the homepage (not a /c/ chat route)?
        const cameFromHome = !prevUrl.includes('/c/');

        if (isChatRoute() && virt.enabled) {
          const newId = getChatId();

          // ── Instant optimise new chats ──
          if (newId) {
            const storedId = cfg.get('lastChatId');
            if (!storedId || newId > storedId) {
              cfg.set('lastChatId', newId);
            }
            if (cfg.get('instantNewChat') && cameFromHome && storedId && storedId < newId) {
              this.tracked.clear();
              this.busy = false;
              this._unlock();
              this.pill?.classList.remove('pushed');
              this.blocker?.classList.remove('on');
              this.panel?.classList.remove('loading');
              const lbl = $('.cgv-label', this.pill);
              if (lbl) lbl.textContent = 'New chat detected';
              this.statusLock = true;
              this.pill?.classList.remove('busy', 'dead', 'mini');
              this.pill?.classList.add('on');
              this._scheduleCollapse(3000);
              this._observeNewChat();
              return;
            }
          }

          this.tracked.clear();
          this.busy = true;
          document.documentElement.classList.add('cgv-loading');
          this._showBusy();
          setTimeout(() => { if (this.busy) { this.pill?.classList.add('pushed'); this.blocker?.classList.add('on'); } }, 400);
          this._optimise(true);
        } else {
          this.busy = false;
          this._unlock();
          this._sync(virt.enabled);
          this.pill?.classList.remove('pushed');
          this.blocker?.classList.remove('on');
        }
      }, 500);
    }

    // ── Wait for <body> ───────────────────────────────────────
    _waitBody() {
      return new Promise(r => {
        if (document.body) return r();
        new MutationObserver((_, o) => { if (document.body) { o.disconnect(); r(); } })
          .observe(document.documentElement, { childList: true });
      });
    }

    // ── Build all UI elements ─────────────────────────────────
    _buildUI(s) {
      // Wrapper
      this.wrap = document.createElement('div');
      this.wrap.className = 'cgv-wrap';

      // Pill
      this.pill = document.createElement('div');
      this.pill.className = 'cgv-pill';
      this.pill.innerHTML = '<div class="cgv-dot"></div><span class="cgv-label">Optimised</span>';
      this.pill.addEventListener('click', () => {
        if (this.busy) return;
        this.pill.classList.toggle('mini');
        if (this.pill.classList.contains('mini')) { clearTimeout(this.collapseTimer); }
        else this._scheduleCollapse();
      });

      // Blocker
      this.blocker = document.createElement('div');
      this.blocker.className = 'cgv-block';
      this.blocker.innerHTML = '<svg viewBox="0 0 24 24"><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg><span>Input temporarily blocked</span>';

      // Panel
      this.panel = this._buildPanel(s);

      // Initial state
      if (this.busy && s.enabled) {
        this._showBusy();
        setTimeout(() => { if (this.busy) { this.pill.classList.add('pushed'); this.blocker.classList.add('on'); } }, 400);
      }

      this.wrap.append(this.panel, this.pill);
      document.body.append(this.wrap, this.blocker);
      this._blockInputs();
    }

    // ── Build settings panel ──────────────────────────────────
    _buildPanel(s) {
      const el = document.createElement('div');
      el.className = 'cgv-panel' + (this.busy ? ' loading' : '');
      el.innerHTML = `
        <div class="cgv-ph"><h3><div class="cgv-hd"></div>GPT Optimum</h3><p>Optimize ChatGPT Performance</p></div>
        <div class="cgv-loading-msg"><div class="cgv-lspin"></div><br>Please wait for chat to load</div>
        <div class="cgv-pc">
          ${this._card('enabled', 'Enable Optimise', 'Unload off-screen items', s.enabled, 'main')}
          ${this._card('instantNewChat', 'Instant New Chats', 'Skip optimisation on new chats', s.instantNewChat, 'dep')}
          ${this._card('aggressive', 'Aggressive Mode', 'Tighter margins, more unloading', s.aggressive, 'dep')}
          ${this._card('debug', 'Debug Mode', 'Highlight unloaded elements', s.debug, 'dep')}
        </div>
        <div class="cgv-ft"><a href="https://github.com/YashRana738/GptOptimum" target="_blank" style="color:inherit;text-decoration:none;opacity:0.7">v2.1 by Yash Rana</a></div>`;

      // Wire toggles
      const enabledTgl = $('[data-key="enabled"] input', el);
      const instantTgl = $('[data-key="instantNewChat"] input', el);
      const aggrTgl = $('[data-key="aggressive"] input', el);
      const debugTgl = $('[data-key="debug"] input', el);
      const deps = $$('.dep', el);

      const syncDeps = () => deps.forEach(c => c.classList.toggle('off', !enabledTgl.checked));
      syncDeps();

      enabledTgl.addEventListener('change', () => {
        const on = enabledTgl.checked;
        cfg.set('enabled', on);
        virt.setEnabled(on);
        syncDeps();
        if (on && isChatRoute()) {
          this.busy = true;
          this.tracked.clear();
          this.scanMessages();
          this._optimise();
        } else if (!on) {
          this.busy = false;
          this._unlock();
        }
        this._sync(on);
      });

      instantTgl.addEventListener('change', () => {
        cfg.set('instantNewChat', instantTgl.checked);
      });

      aggrTgl.addEventListener('change', () => {
        cfg.set('aggressive', aggrTgl.checked);
        virt.setAggressive(aggrTgl.checked);
      });

      debugTgl.addEventListener('change', () => {
        cfg.set('debug', debugTgl.checked);
        virt.setDebug(debugTgl.checked);
      });

      // Card click → toggle
      $$('.cgv-card', el).forEach(card => {
        card.addEventListener('click', e => {
          if (e.target.tagName === 'INPUT' || e.target.classList.contains('cgv-sl')) return;
          if (card.classList.contains('off')) return;
          const inp = $('input', card);
          inp.checked = !inp.checked;
          inp.dispatchEvent(new Event('change'));
        });
      });

      return el;
    }

    _card(key, label, desc, checked, cls) {
      return `<div class="cgv-card ${cls}" data-key="${key}"><div class="cgv-ci"><span class="cgv-cl">${label}</span><span class="cgv-cd">${desc}</span></div><label class="cgv-sw"><input type="checkbox"${checked ? ' checked' : ''}><span class="cgv-sl"></span></label></div>`;
    }

    // ── Indicator state management ────────────────────────────
    _sync(enabled) {
      if (!this.pill) return;
      const lbl = $('.cgv-label', this.pill);

      this.pill.classList.remove('busy', 'dead', 'mini', 'pushed');
      clearTimeout(this.collapseTimer);
      this.blocker?.classList.remove('on');

      // Update panel loading state
      this.panel?.classList.toggle('loading', this.busy);

      if (this.busy && virt.enabled) {
        lbl.textContent = 'Optimising';
        this.pill.classList.add('busy', 'on');
        return;
      }

      // Always show the pill — green when enabled, red dot when disabled
      this.pill.classList.add('on');
      if (enabled) {
        if (!this.statusLock) lbl.textContent = 'Optimised';
        this._scheduleCollapse();
      } else {
        if (!this.statusLock) lbl.textContent = 'Disabled';
        this.pill.classList.add('dead');
        // Collapse to compact dot when disabled
        this._scheduleCollapse(800);
      }
    }

    _showBusy() {
      if (!this.pill) return;
      const lbl = $('.cgv-label', this.pill);
      lbl.textContent = 'Optimising';
      this.pill.classList.remove('mini', 'dead');
      this.pill.classList.add('busy', 'on');
      this.panel?.classList.add('loading');
    }

    _scheduleCollapse(delay = 4000) {
      clearTimeout(this.collapseTimer);
      this.collapseTimer = setTimeout(() => {
        this.statusLock = false;
        if (!this.busy && this.pill) this.pill.classList.add('mini');
      }, delay);
    }

    // ── Optimisation cycle ────────────────────────────────────
    _optimise(requireClear = false) {
      let ticks = 0, cleared = !requireClear;
      const iv = setInterval(() => {
        ticks++;
        const msgs = $$(MSG_SEL);
        if (requireClear && !cleared) {
          if (msgs.length === 0 || ticks > 50) { cleared = true; ticks = 0; }
          return;
        }
        if (msgs.length > 0 || ticks > 600) {
          clearInterval(iv);
          if (msgs.length > 0) this.scanMessages();
          setTimeout(() => {
            this.busy = false;
            this.pill?.classList.remove('pushed');
            this.blocker?.classList.remove('on');
            this.panel?.classList.remove('loading');
            this._unlock();
            this._sync(virt.enabled);
          }, 400);
        }
      }, 100);
    }

    // ── Background observer for new chats (no blocking) ──────
    _observeNewChat() {
      let ticks = 0;
      const iv = setInterval(() => {
        ticks++;
        const msgs = $$(MSG_SEL);
        if (msgs.length > 0) {
          clearInterval(iv);
          this.scanMessages();
          this._sync(virt.enabled);
        }
        if (ticks > 300) clearInterval(iv); // 30s timeout
      }, 100);
    }

    _unlock() {
      document.documentElement.classList.remove('cgv-loading');
    }

    // ── Message tracking ──────────────────────────────────────
    scanMessages() { $$(MSG_SEL).forEach(m => this._track(m)); }

    _track(el) {
      if (this.tracked.has(el)) return;
      this.tracked.add(el);
      el.classList.add('cgv-msg');
      virt.observe(el);
    }

    _observe() {
      new MutationObserver(muts => {
        for (const m of muts) {
          if (m.type !== 'childList') continue;
          for (const n of m.addedNodes) {
            if (n.nodeType !== 1) continue;
            if (n.matches?.(MSG_SEL)) this._track(n);
            $$(MSG_SEL, n).forEach(c => this._track(c));
          }
        }
      }).observe(document.body, { childList: true, subtree: true });
    }
  }

  new App().run();
})();