JanitorAI – Right Arrow Generate Indicator

The right arrow changes color when the next click would generate a new output.

// ==UserScript==
// @name         JanitorAI – Right Arrow Generate Indicator
// @namespace    gf:jai-green-right
// @version      1.0.6
// @description  The right arrow changes color when the next click would generate a new output.
// @match        https://janitorai.com/chats*
// @license      MIT
// @grant        unsafeWindow
// @inject-into  page
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  const W = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;

  const SEL = {
    right:  'button[aria-label="Right"]',
    left:   'button[aria-label="Left"]',
    slider: 'div[class*="_botChoicesSlider_"]',
  };

  // Styles
  const style = document.createElement('style');
  style.textContent = `
    .jai-green-arrow  { outline: 3px solid #22c55e !important; outline-offset: 2px !important; border-radius: 999px !important; }
    .jai-green-arrow  svg { color: #22c55e !important; fill: currentColor !important; }
    .jai-yellow-arrow { outline: 3px solid #f59e0b !important; outline-offset: 2px !important; border-radius: 999px !important; }
    .jai-yellow-arrow svg { color: #f59e0b !important; fill: currentColor !important; }
  `;
  document.documentElement.appendChild(style);

  const $  = (s, r = document) => r.querySelector(s);
  const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));

  let ctx = { right: null, left: null, slider: null, sliderMO: null, rebinderMO: null, pulseTimer: null };

  // ---------- utils ----------
  function isVisible(el) {
    if (!el) return false;
    const cs = getComputedStyle(el);
    if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
    const r = el.getBoundingClientRect();
    return r.width > 8 && r.height > 8;
  }

  function getViewport(slider) {
    let el = slider?.parentElement;
    while (el && el !== document.body) {
      const ox = getComputedStyle(el).overflowX;
      if (ox && ox !== 'visible') return el;
      el = el.parentElement;
    }
    return slider?.parentElement || slider;
  }

  // ---------- pairing ----------
  function bottomMostRight() {
    const candidates = $$(SEL.right).filter(isVisible);
    if (!candidates.length) return null;
    candidates.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
    return candidates[candidates.length - 1];
  }

  function findNearestSliderFrom(node) {
    let el = node;
    for (let i = 0; el && i < 12; i++, el = el.parentElement) {
      const s = $(SEL.slider, el);
      if (s && isVisible(s)) return s;
    }
    const all = $$(SEL.slider).filter(isVisible);
    return all[all.length - 1] || null;
  }

  function pair() {
    const right = bottomMostRight();
    const slider = right ? findNearestSliderFrom(right) : null;
    const left = slider ? slider.parentElement?.querySelector(SEL.left) : null;

    const changed = right !== ctx.right || slider !== ctx.slider || left !== ctx.left;
    if (!changed) return;

    if (ctx.sliderMO) ctx.sliderMO.disconnect();

    ctx.right = right;
    ctx.slider = slider;
    ctx.left = left;

    if (ctx.right) ctx.right.addEventListener('click', () => pulse(3000), { capture: true });
    if (ctx.left)  ctx.left.addEventListener('click',  () => pulse(2000), { capture: true });

    if (ctx.slider) {
      ctx.sliderMO = new MutationObserver(() => {
        setTimeout(updateTint, 30);
        setTimeout(updateTint, 200);
        setTimeout(updateTint, 700);
      });
      ctx.sliderMO.observe(ctx.slider, {
        childList: true, subtree: true,
        attributes: true, attributeFilter: ['style', 'class']
      });

      const vp = getViewport(ctx.slider);
      vp.addEventListener('scroll', updateTint, { passive: true });
      vp.addEventListener('transitionend', updateTint, { passive: true });
      vp.addEventListener('animationend', updateTint, { passive: true });
    }

    updateTint();
  }

  // ---------- index detection (no :scope; use children) ----------
  function sliderItems(slider) {
    return Array.from(slider?.children || []).filter(isVisible);
  }

  function indexFromTransform(slider, items) {
    const inline = slider.style.transform || '';
    let m = inline.match(/translateX\((-?\d+(?:\.\d+)?)%\)/);
    if (m) return clamp(Math.round(Math.abs(parseFloat(m[1])) / 100), 0, items.length - 1);

    const comp = getComputedStyle(slider).transform;
    if (comp && comp !== 'none') {
      const mm = comp.match(/matrix\(([^)]+)\)/);
      if (mm) {
        const parts = mm[1].split(',').map(s => parseFloat(s.trim()));
        const tx = parts[4] || 0;
        const w  = items[0]?.getBoundingClientRect().width || 1;
        return clamp(Math.round(Math.abs(tx) / w), 0, items.length - 1);
      }
    }
    return null;
  }

  function indexByViewport(slider, items) {
    const vp = getViewport(slider).getBoundingClientRect();
    const centerV = vp.left + vp.width / 2;
    let best = 0, bestDist = Infinity;
    for (let i = 0; i < items.length; i++) {
      const r = items[i].getBoundingClientRect();
      const dist = Math.abs((r.left + r.width / 2) - centerV);
      if (dist < bestDist) { bestDist = dist; best = i; }
    }
    return best;
  }

  function currentIndex(slider) {
    const items = sliderItems(slider);
    if (!items.length) return 0;
    return indexFromTransform(slider, items) ?? indexByViewport(slider, items);
  }

  function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); }

  // ---------- tint logic ----------
  function updateTint() {
    if (!ctx.right || !ctx.slider) return;
    const items = sliderItems(ctx.slider);
    if (!items.length) {
      ctx.right.classList.remove('jai-green-arrow', 'jai-yellow-arrow');
      return;
    }
    const idx = currentIndex(ctx.slider);
    const last = items.length - 1;
    const secondLast = last - 1;

    ctx.right.classList.remove('jai-green-arrow', 'jai-yellow-arrow');
    if (idx >= last) {
      ctx.right.classList.add('jai-green-arrow');      // last -> next click generates
    } else if (idx === secondLast) {
      ctx.right.classList.add('jai-yellow-arrow');     // one before last
    }
  }

  // ---------- keep it fresh ----------
  function pulse(ms) {
    if (ctx.pulseTimer) W.clearInterval(ctx.pulseTimer);
    const endAt = performance.now() + ms;
    ctx.pulseTimer = W.setInterval(() => {
      if (performance.now() > endAt) {
        W.clearInterval(ctx.pulseTimer);
        ctx.pulseTimer = null;
      }
      pair();
      updateTint();
    }, 160);
  }

  function startRebinder() {
    if (ctx.rebinderMO) ctx.rebinderMO.disconnect();
    ctx.rebinderMO = new MutationObserver(() => {
      if (!document.contains(ctx.right) || !document.contains(ctx.slider)) pair();
    });
    ctx.rebinderMO.observe(document.body, { childList: true, subtree: true });
  }

  // Pulse after generation POSTs (hook in PAGE context for TM)
  (function patchNetwork() {
    const looksGen = (url, method) =>
      method === 'POST' &&
      /janitorai\.com/i.test(url) &&
      /(generate|regenerate|completion|completions|respond|messages|chat)/i.test(url);

    const _fetch = W.fetch;
    try {
      W.fetch = function(resource, init = {}) {
        try {
          const url = typeof resource === 'string' ? resource : resource.url;
          const method = (init?.method || 'GET').toUpperCase();
          if (looksGen(url, method)) pulse(5000);
        } catch {}
        return _fetch.apply(this, arguments);
      };
    } catch {}

    const XO = W.XMLHttpRequest && W.XMLHttpRequest.prototype.open;
    const XS = W.XMLHttpRequest && W.XMLHttpRequest.prototype.send;
    if (XO && XS) {
      W.XMLHttpRequest.prototype.open = function(method, url) {
        this._m = (method || 'GET').toUpperCase();
        this._u = url || '';
        return XO.apply(this, arguments);
      };
      W.XMLHttpRequest.prototype.send = function(body) {
        try {
          if (looksGen(this._u || '', this._m || 'GET')) pulse(5000);
        } catch {}
        return XS.apply(this, arguments);
      };
    }
  })();

  function hookSpa() {
    const H = W.history;
    if (H && H.pushState) {
      const origPush = H.pushState;
      H.pushState = function () {
        const r = origPush.apply(this, arguments);
        W.setTimeout(() => { pair(); updateTint(); }, 400);
        return r;
      };
      W.addEventListener('popstate', () => W.setTimeout(() => { pair(); updateTint(); }, 400));
    }
    W.addEventListener('resize', updateTint);
  }

  function boot() {
    pair();
    startRebinder();
    hookSpa();

    // initial retries for late-mount
    let tries = 0;
    const iv = W.setInterval(() => {
      pair();
      if (++tries > 40 || (ctx.right && ctx.slider)) W.clearInterval(iv);
    }, 200);
  }

  boot();
})();